| author | |
| committer | |
| log | 4292e4deb721a9526b18e9d9385d225c033b245c |
| tree | 9f5b4fa9e49dc1994ada3a0db13e3e550ef05955 |
| parent | 216e64b3c750a6df75ac13a7f3779327f01cbe9e |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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-554 files changed, 6610 insertions(+), 4329 deletions(-)
crates/notebook/Cargo.toml+1| ... | @@ -21,6 +21,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true | ... | @@ -21,6 +21,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true |
| 21 | zeroize = { version = "1.9.0", optional = true } | 21 | zeroize = { version = "1.9.0", optional = true } |
| 22 | 22 | ||
| 23 | [dev-dependencies] | 23 | [dev-dependencies] |
| 24 | libc = "0.2" | ||
| 24 | serde_json = "1" | 25 | serde_json = "1" |
| 25 | tempfile = "3" | 26 | tempfile = "3" |
| 26 | 27 |
crates/notebook/README.md+91-194| ... | @@ -2,184 +2,97 @@ | ... | @@ -2,184 +2,97 @@ |
| 2 | 2 | ||
| 3 | The application-facing crate: notebook discovery, a durable local replica with | 3 | The application-facing crate: notebook discovery, a durable local replica with |
| 4 | reconnect reconciliation, external-asset caching, recovery export and optional | 4 | reconnect reconciliation, external-asset caching, recovery export and optional |
| 5 | embedded SMB access. The replica stores the last observed remote image of one section, | 5 | embedded SMB access. A replica keeps one section's queue in a local SQLite database: |
| 6 | the working image as its difference from that, and the editor's intents in a | 6 | the image the queued edits apply to (the base, in 16 KiB chunks), the edits as |
| 7 | local SQLite database; a save writes the revision it appended, not the section. `sync_once` provides a | 7 | `onestore::op` ops in publication batches, and picture and attachment bytes once |
| 8 | reconciliation step and `start_sync` owns automatic polling and reconnects. Local | 8 | each by hash. Local success does not acknowledge publication to a shared notebook. |
| 9 | success does not acknowledge publication to a shared notebook. | 9 | |
| 10 | 10 | ```text | |
| 11 | ## Saving pages | 11 | editor ── Edit ──► section thread ─ Section::apply, INSERT edit ─┐ |
| 12 | 12 | (the parsed section: base + sealed + open) │ one fsync per burst | |
| 13 | The editor works on `onestore::page::Page` values and saves whole pages. `save` | 13 | sync thread: remote.stamp() == base ? publish(sealed batch) ──────┤ base chunks += transaction |
| 14 | diffs the supplied model against the page stored in the supplied local snapshot, | 14 | otherwise read once, replay the queue on it ─────────┘ conflict, or base := remote |
| 15 | writes the difference into the working image and queues one | 15 | ``` |
| 16 | `Operation::Page(PageIntent { before, after, author })`; `before` is the page the | 16 | |
| 17 | edit started from and is the precondition reconciliation checks. Text, styles, | 17 | ## Edits |
| 18 | paragraph formatting, hyperlinks (external and internal), bullets, numbering, note tags, table rows, columns, cell shading, nested tables, pictures (insertion in paragraphs or on the page, position, size, description), attachments, ink strokes, equations, paragraph structure, | 18 | |
| 19 | outline layout, insertions and deletions are all differences between `before` and | 19 | `Replica::apply(author, edit)` applies an `onestore::op::Edit` to the section the |
| 20 | `after`; the library never sees editor operations. | 20 | queue leaves and returns its id once written; a refused edit returns `Rejected` |
| 21 | and changes nothing. `page(space)` and `pages()` read what the queue leaves, for | ||
| 22 | opening and reloading. Edits collect in an open batch until the sync thread seals | ||
| 23 | it, so edits arriving while a publication is in flight publish together. | ||
| 21 | 24 | ||
| 22 | ```no_run | 25 | ```no_run |
| 23 | use onestore::{ExGuid, page::Page}; | 26 | use onestore::{ExGuid, op::{Edit, Op, PageOp}}; |
| 24 | use notebook::Replica; | 27 | use notebook::Replica; |
| 25 | # fn example(path: &std::path::Path, source: &[u8], space: ExGuid, edited: &Page) | 28 | # fn example(path: &std::path::Path, source: &[u8], space: ExGuid, text: ExGuid) |
| 26 | # -> Result<(), Box<dyn std::error::Error>> { | 29 | # -> Result<(), Box<dyn std::error::Error>> { |
| 27 | // `space` identifies the page; `edited` is the editor's current model of it. | ||
| 28 | let cache = Replica::create(path, source)?; | 30 | let cache = Replica::create(path, source)?; |
| 29 | let snapshot = cache.snapshot()?; | 31 | let typed = PageOp::Text { text, range: 0..0, with: "Hello ".into() }; |
| 30 | let local_id = cache.save(&snapshot, space, edited, "Author")?; | 32 | let id = cache.apply("Author", Edit { at: 133_000_000_000_000_000, ops: vec![Op::Page { space, op: typed }] })?; |
| 31 | drop(cache); | 33 | drop(cache); |
| 32 | 34 | ||
| 33 | let reopened = Replica::open(path)?; | 35 | let reopened = Replica::open(path)?; |
| 34 | let pending = reopened.pending()?; | 36 | assert_eq!(reopened.pending()?.last().map(|edit| edit.id), Some(id)); |
| 35 | assert_eq!(pending.last().map(|edit| edit.id), local_id); | ||
| 36 | # Ok(()) | 37 | # Ok(()) |
| 37 | # } | 38 | # } |
| 38 | ``` | 39 | ``` |
| 39 | 40 | ||
| 40 | Saves coalesce the way OneNote's own autosave does: while the newest queued intent | 41 | Share one `Replica` between threads. Keep the cache on a local filesystem: the |
| 41 | for the same page has not been attempted, a later save replaces its `after` model | 42 | connection holds exclusive ownership between transactions, and a second open fails |
| 42 | under the same ID. Once an intent has been attempted or holds a conflict it is | 43 | busy. No network wait occurs in a local edit. After a database error the section |
| 43 | never rewritten, nor is the intent a synchronization step has selected for | 44 | thread rereads the queue. |
| 44 | publication; the next save then queues a new intent. A save whose model equals | ||
| 45 | the stored page returns `None`. `PageIntent::text_change` describes an intent that | ||
| 46 | changes exactly one paragraph's text as the text object, its previous text, the | ||
| 47 | replaced UTF-16 range and the replacement. | ||
| 48 | |||
| 49 | Share one `Replica` between application threads. Each save compares its supplied | ||
| 50 | snapshot under the cache transaction; stale snapshots return `Io(ResourceBusy)`. | ||
| 51 | The intent and its resulting image commit together. Keep the cache on a local | ||
| 52 | filesystem: the connection holds exclusive ownership between transactions, and a | ||
| 53 | second open fails busy. No network wait occurs in a local save. After a database | ||
| 54 | error, reopen and inspect the durable state before retrying. | ||
| 55 | 45 | ||
| 56 | ## Sessions | 46 | ## Sessions |
| 57 | 47 | ||
| 58 | `session::Notebook::open(root, cache_dir)` discovers a notebook directory and | 48 | `session::Notebook::open(root, cache_dir)` discovers a notebook directory and |
| 59 | `session::Section::open(file, cache_dir, notify)` opens one section file through | 49 | `session::Section::open(file, cache_dir, notify)` opens one section file through |
| 60 | a replica stored under the cache directory, named by the section's document | 50 | a replica named by the section's document identity, so the same file reopens the |
| 61 | identity so the same file reopens the same queue after a relaunch. A section | 51 | same queue after a relaunch. A section is `Send + Sync`: `apply(author, edit)` |
| 62 | publishes in the background to the file itself under OneNote-compatible | 52 | returns at once and reports a refusal as `Event::Rejected`; `events()` drains |
| 63 | exclusion. `pages()` lists page spaces and titles from the local image, | 53 | remote changes (`Changed(spaces)`), publication outcomes, unreachable files and |
| 64 | `page(space)` returns the model to edit (its `identity` and the section's | 54 | failures; `notify` runs on a background thread whenever an event waits. |
| 65 | `identity()` feed `onestore::page::link::internal_link`), and `save(space, before, after, | 55 | `conflict()`, `remote_page(space)` and `resolve(id, Resolution)` present and end a |
| 66 | author)` queues the edited model: `Save::Queued(id)` is durable locally, | 56 | conflict; `release(id, archive, Resolution)` ends an uncertain attempt. |
| 67 | `Save::Unchanged` means the model equals the stored page, and `Save::Stale` | 57 | `import_page` creates a page holding a copy of another; `delete_pages` removes pages. |
| 68 | means the stored page no longer matches `before` because the section changed | 58 | |
| 69 | underneath the editor, so the page must be reloaded before saving again. | 59 | Until the application emits ops, `save`, `queue_save`, `saved`, `saving`, |
| 70 | `queue_save(space, before, after, author)` does the same on the section's save | 60 | `conflicts`, `queue`, `review` and `Event::Refreshed` keep the whole-page API: a |
| 71 | thread and returns at once, so an editor's frame never waits on the write; | 61 | save lowers the page it is given against the stored page (`onestore::op::lower_page`) |
| 72 | `saved()` drains the outcomes. A queued save whose `before` is the previous | 62 | and applies the ops. |
| 73 | one's `after` continues it, needing no reload in between, and a run of such | 63 | |
| 74 | saves waiting together is written once. | 64 | `Section::resume(file, replica, notify)` starts from an owned `Replica` without |
| 75 | `events()` drains what the synchronization thread reported since the last | 65 | consulting the remote file; with the `smb` feature, `Section::resume_smb(path, |
| 76 | poll: refreshes, attempt outcomes and unreachable files; `notify` runs | 66 | replica, limit, connect, notify)` binds a share-relative path, `connect` running on |
| 77 | whenever an event or save outcome is available so the application can wake its | 67 | the worker again after transport failure. |
| 78 | event loop. `close()` finishes queued saves and stops publication. | ||
| 79 | |||
| 80 | `Event::Unreachable` retains an `io::Error`: callers can distinguish permission | ||
| 81 | denial, missing targets, timeouts, and connection failures through `kind()` without | ||
| 82 | parsing display text. Document/cache failures are reported as `Event::Failed` and | ||
| 83 | stop 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 | ||
| 87 | the cache location and publication path for offline relaunch. Local pages and | ||
| 88 | saves remain available while the target is absent; synchronization verifies the | ||
| 89 | document identity before adopting or publishing remote content. A different | ||
| 90 | document stops the worker and retains the pending local edits. `Section::open` | ||
| 91 | remains the online convenience constructor that discovers the identity from the | ||
| 92 | file and creates or reopens its cache. | ||
| 93 | |||
| 94 | With the `smb` feature, `Section::resume_smb(path, replica, limit, connect, | ||
| 95 | notify)` binds the same session operations to a share-relative path. `connect` | ||
| 96 | returns a new SMB client on the synchronization worker and is called again after | ||
| 97 | transport failure. The caller supplies credentials there, outside the replica | ||
| 98 | schema; 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 | ||
| 103 | and section entry as one publication. Subsequent saves of the new page use the | ||
| 104 | intent's stable identities immediately after local acknowledgement. Independent | ||
| 105 | page additions rebase against the current section order; duplicate titles remain | ||
| 106 | distinct. An unavailable or no-longer-leading insertion anchor produces | ||
| 107 | `StructureChanged`. `rebase_page_creation_conflict(id, local, remote, before)` | ||
| 108 | reviews a replacement anchor against both cache images while retaining the new | ||
| 109 | page and dependent object identities. Existing page identities require | ||
| 110 | reconciliation; a matching page alone does not establish a receipt. | ||
| 111 | |||
| 112 | `pages(snapshot, edits)` queues a slice of core `PageEdit` intents as one atomic | ||
| 113 | publication. `PagePosition::Keep` preserves remote movement during indentation-only | ||
| 114 | edits. Every requested level remains explicit: a competing remote level produces | ||
| 115 | `StructureChanged` unless it already matches the requested level. Moves compare | ||
| 116 | the selected page's position relative to surviving observed pages; an unchanged | ||
| 117 | or already-satisfied position can proceed, and new remote pages remain present. | ||
| 118 | Indistinguishable competing moves retain a conflict and the complete local image. | ||
| 119 | `rebase_pages_conflict(id, local, remote, edits)` reviews the batch against both | ||
| 120 | cache images. Use `PageEdit::reposition(position, level)` on the retained intents | ||
| 121 | to revise anchors or indentation; replacing page or allocated series identities | ||
| 122 | is rejected. Dependent edits remain queued. Attempt evidence includes every | ||
| 123 | changed space plus the receipt space when that space is unchanged. An existing | ||
| 124 | section revision alone cannot confirm a page edit. The | ||
| 125 | [offline page corpus](../../corpus/page-lifecycle/offline-edits/README.md) | ||
| 126 | contains native comparisons, reproduction commands and stateful sanitizer seeds. | ||
| 127 | |||
| 128 | `delete_pages(snapshot, pages)` queues the permanent removal of page spaces; | ||
| 129 | a page already absent remotely produces `TargetUnavailable`. | ||
| 130 | 68 | ||
| 131 | ## Reconciliation | 69 | ## Reconciliation |
| 132 | 70 | ||
| 133 | `sync_once(&mut remote)` processes the oldest pending intent through a `Remote` | 71 | `sync_once(&mut remote)` returns what one step did as `Synced { edit, changed }`: |
| 134 | implementation, returning its ID and `EditStatus`. When the remote page still | 72 | the state the step's batch reached, named by its newest edit, and the pages a remote |
| 135 | equals `before`, the intent publishes as prepared. When the remote page changed, | 73 | change replaced. While the remote's stamp (header and length) equals the base's, the |
| 136 | a three-way merge keeps everything the remote changed and re-applies the local | 74 | oldest sealed batch publishes as its `Transaction`, and the base's chunks take its |
| 137 | changes wherever the two sides touched different objects, fields or text ranges: | 75 | writes; nothing is read. When the stamp moved, the remote image is read once and the |
| 138 | a local text edit merges with a remote edit elsewhere in the same paragraph, an | 76 | queue replays on it (`merge.rs`): an op whose objects the remote left alone applies |
| 139 | outline move merges with a remote text change, a new paragraph survives a remote | 77 | as it is; a text op shifts past the remote's changes to the same text when its |
| 140 | deletion elsewhere. Any overlap retains `Conflict(ContentChanged)` together with | 78 | range stays clear of them; a move, deletion or property the remote already made is |
| 141 | the complete local image and the last observed remote image returned by | 79 | done; a page the remote already holds as the local edits leave it drops their ops. |
| 142 | `remote_snapshot`; a page removed remotely retains `TargetUnavailable`, and a | 80 | Anything else is a `Conflict` on the batch: the queue stays on its base and the |
| 143 | page the writer can no longer express retains `UnsupportedEdit`. | 81 | remote image is kept for review. `resolve(id, Mine)` rewrites the remote page to the |
| 144 | 82 | local one (`lower_page`, once), `Theirs` drops the local ops on that page; either | |
| 145 | Publication attempts are recorded before network I/O. A retained attempt | 83 | applies from the conflicted batch on. |
| 146 | requires its recorded revision to remain present in the remote, or the remote | 84 | |
| 147 | page to equal the intent's `after` model exactly, followed by comparison, | 85 | Publication attempts are recorded before network I/O. An attempt confirms only |
| 148 | flushing and refreshed header version metadata before acknowledgement. Otherwise | 86 | when the remote holds its revisions, or every page it changed as it changed them, |
| 149 | the attempt remains `AwaitingConfirmation`; an uncertain publication is never | 87 | followed by `Remote::confirm`; otherwise it stays `AwaitingConfirmation` and is never |
| 150 | replayed. A durable `Published` receipt survives reopening. Transport errors | 88 | replayed. `release(id, archive, Resolution)` exports the queue first, then |
| 151 | return `Error::Remote` or `Error::RemoteIo`; inspect `status(id)` after the error | 89 | publishes the batch again (`Mine`) or abandons every unpublished edit (`Theirs`). |
| 152 | to distinguish a retained attempt from a pending edit or receipt. The error return | 90 | A durable `Published` receipt survives reopening. |
| 153 | does not roll back a locally acknowledged intent. | 91 | |
| 154 | 92 | A schema-14 cache is converted when opened: it is exported to | |
| 155 | The current operation processes one queue head; while edits remain, `snapshot` | 93 | `<cache>.v14-recovery`, each queued page is lowered against the page the conversion |
| 156 | preserves the complete local working image. An empty queue can refresh from the | 94 | has so far, and every converted page must equal the page in the old working image, |
| 157 | remote image. Synchronization holds a separate owner lock, so local saves can | 95 | or the conversion rolls back and the open fails naming the archive. |
| 158 | continue during network waits; competing synchronization calls return `WouldBlock`. | ||
| 159 | |||
| 160 | `review_page(id, local, remote, after)` resolves the oldest page conflict with a | ||
| 161 | model the user reviewed against the current remote page: the intent's `before` | ||
| 162 | becomes that remote page and its `after` the reviewed model, under the same ID, | ||
| 163 | in 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, | ||
| 166 | so another overlapping remote edit can produce a new conflict. Pending intents | ||
| 167 | and uncertain attempts cannot be reviewed. | ||
| 168 | |||
| 169 | An attempt that stays `AwaitingConfirmation` is released by review, never by | ||
| 170 | replay: `release_attempt(id, local, remote, archive, after)` on the replica | ||
| 171 | (`release` on a session) exports the branch to a new archive, then retires | ||
| 172 | the oldest edit's attempt. `Some(page)` continues from a page reviewed | ||
| 173 | against the current remote image as a fresh intent under the same id, | ||
| 174 | keeping later edits; `None` abandons the local branch, whose ids report | ||
| 175 | `EditStatus::Archived { archive }` from then on, and the working image | ||
| 176 | returns to the remote image. Neither writes a receipt: the uncertain | ||
| 177 | publication may or may not have landed, and the archive is the record of | ||
| 178 | what was attempted. | ||
| 179 | |||
| 180 | Opening recognizes the application identity and the current schema version only; | ||
| 181 | caches and archives written by other versions are refused unchanged. Until the | ||
| 182 | application is usable end to end there are no migrations. | ||
| 183 | 96 | ||
| 184 | With the optional `smb` feature, `SmbRemote::new(client, path, limit)` binds an | 97 | With the optional `smb` feature, `SmbRemote::new(client, path, limit)` binds an |
| 185 | `notebook::smb::Client` to one share-relative file and snapshot limit. Remote identity uses the logical root | 98 | `notebook::smb::Client` to one share-relative file and snapshot limit. Remote identity uses the logical root |
| ... | @@ -188,18 +101,15 @@ object space, which survives the tested native compaction that replaces the file | ... | @@ -188,18 +101,15 @@ object space, which survives the tested native compaction that replaces the file |
| 188 | An `Arc<Replica>` can own one background worker. Supply a connection factory, poll | 101 | An `Arc<Replica>` can own one background worker. Supply a connection factory, poll |
| 189 | interval and observer; successful publications drain immediately, durable local | 102 | interval and observer; successful publications drain immediately, durable local |
| 190 | edits wake the worker, and `wake()` requests an immediate reachability retry. | 103 | edits wake the worker, and `wake()` requests an immediate reachability retry. |
| 191 | While nothing is queued, a remote whose `Remote::stamp` (its header and length, read | 104 | While nothing is queued, or the queue waits on a remote that has not changed since, |
| 192 | without coordination) equals the last observed image's is not read again; publishing | 105 | a remote whose `Remote::stamp` holds is not read again. |
| 193 | against an unchanged stamp needs no read either. | ||
| 194 | Transport failures discard the old connection and retry through the factory; | 106 | Transport failures discard the old connection and retry through the factory; |
| 195 | Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy` | 107 | Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy` |
| 196 | reuse the connection. Contended `NotCommitted` operations use randomized backoff, | 108 | reuse the connection. Contended `NotCommitted` operations use randomized backoff, |
| 197 | capped at one second, to separate competing retry cycles. Cancellation interrupts this delay; local wake notifications | 109 | capped at one second, to separate competing retry cycles. Cancellation interrupts this delay; local wake notifications |
| 198 | remain coalesced until its end. | 110 | remain coalesced until its end. |
| 199 | `RemoteIo` distinguishes connection/read failures from | 111 | `RemoteIo` distinguishes connection/read failures from |
| 200 | local `Io` errors. Cache/document errors stop the worker. Observers receive every | 112 | local `Io` errors. Cache/document errors stop the worker. |
| 201 | attempt's result on the worker thread, including unchanged conflict/uncertain | ||
| 202 | statuses; durable edit state remains available through `status`. | ||
| 203 | 113 | ||
| 204 | ```no_run | 114 | ```no_run |
| 205 | # #[cfg(feature = "smb")] | 115 | # #[cfg(feature = "smb")] |
| ... | @@ -229,35 +139,25 @@ worker.stop()?; | ... | @@ -229,35 +139,25 @@ worker.stop()?; |
| 229 | ``` | 139 | ``` |
| 230 | 140 | ||
| 231 | `stop()` cancels future steps and joins the worker, returning a fatal cache error | 141 | `stop()` cancels future steps and joins the worker, returning a fatal cache error |
| 232 | or worker panic. Dropping it requests cancellation without waiting. An in-flight | 142 | or worker panic. Dropping it requests cancellation without waiting. Remote |
| 233 | step completes before releasing ownership; a replacement worker cannot start | 143 | operations and callbacks must have bounded execution times if shutdown latency |
| 234 | while the old one still owns the replica. Remote operations and callbacks must | 144 | matters. Credentials belong to the factory, not the cache database. |
| 235 | have bounded execution times if shutdown latency matters. A dropped worker can | 145 | |
| 236 | briefly retain the cache; use `stop()` before requiring an immediate reopen. | 146 | Creation refuses existing paths. Opening validates database integrity and replays |
| 237 | Cancellation and application restart retain pending edits and publication attempts. | 147 | the queue on its base. SQLite runs in WAL mode under exclusive locking with FULL |
| 238 | Credentials belong to the factory, not the cache database. | 148 | synchronization and fullfsync, each queried back: every commit is durable, and the only |
| 239 | 149 | file beside the cache is `<cache>-wal`, which holds committed pages until a checkpoint | |
| 240 | Creation refuses existing paths. Opening recognizes the application identity and | 150 | and must travel with the cache when it is copied. Recovery archives are single files. |
| 241 | schema version, validates database integrity and both notebook images, and rejects | 151 | The cache contains notebook content. |
| 242 | unsupported journal modes without converting them. Failed initialization preserves | ||
| 243 | the file for inspection. SQLite uses DELETE journaling, EXTRA synchronization and | ||
| 244 | fullfsync; each required setting is queried back. | ||
| 245 | |||
| 246 | The cache and its pending intents contain notebook content. The core `onestore` | ||
| 247 | crate stays independent of SQLite and network runtimes. Device and simulator | ||
| 248 | examples compile and link for iOS; recorded process-interruption tests do not | ||
| 249 | establish physical power-loss durability. Evidence is tracked in [Milestone 9](../../evidence/MILESTONE9.md). | ||
| 250 | 152 | ||
| 251 | The SMB-enabled `smb_offline_client` example is an owned-lab workload for | 153 | The SMB-enabled `smb_offline_client` example is an owned-lab workload for |
| 252 | `tools/native_collaboration.py --offline --embedded-smb`. It separates local | 154 | `tools/native_collaboration.py --offline --embedded-smb`; its append-specific |
| 253 | acknowledgements, remote publication attempts, persisted receipts and cache reopen | 155 | conflict policy lives in the client. |
| 254 | checks. Its append-specific conflict review policy lives in the test client; | ||
| 255 | the library continues to preserve conflicts requiring an explicit decision. | ||
| 256 | 156 | ||
| 257 | ## Recovery archives | 157 | ## Recovery archives |
| 258 | 158 | ||
| 259 | `export_recovery(new_path)` captures both complete notebook images, the intent | 159 | `export_recovery(new_path)` captures the base and remote images, the edit queue, |
| 260 | queue, uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one | 160 | uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one |
| 261 | SQLite snapshot. It refuses existing destinations and leaves the live queue | 161 | SQLite snapshot. It refuses existing destinations and leaves the live queue |
| 262 | unchanged. Export to a local directory from a background thread: copying holds | 162 | unchanged. Export to a local directory from a background thread: copying holds |
| 263 | the cache mutex while capturing the database. Failure after the final rename can | 163 | the cache mutex while capturing the database. Failure after the final rename can |
| ... | @@ -448,9 +348,6 @@ physical power-loss durability. | ... | @@ -448,9 +348,6 @@ physical power-loss durability. |
| 448 | ## Queue measurement | 348 | ## Queue measurement |
| 449 | 349 | ||
| 450 | `cargo run -p notebook --release --example queue_scale -- NEW_DIRECTORY 1000` | 350 | `cargo run -p notebook --release --example queue_scale -- NEW_DIRECTORY 1000` |
| 451 | measures alternating-page saves that retain separate queue IDs, cache reopen, | 351 | measures alternating-page edits, cache reopen, recovery export and one local-file |
| 452 | recovery export and local-file publication. JSON lines record each acknowledgement | 352 | publication of the queue. `keystroke_probe PARAGRAPHS KEYSTROKES` types into a large |
| 453 | and publication latency plus image/cache sizes. The workload verifies every ID | 353 | page and reports the bytes the cache and the section file write per keystroke. |
| 454 | and both final page titles, then independently reopens the recovery archive. | ||
| 455 | Use an external resource monitor for peak memory; these local-file timings do not | ||
| 456 | measure SMB or phone performance. |
crates/notebook/examples/cache_probe.rs+75-108| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | use notebook::{Operation, Replica}; | 1 | use notebook::Replica; |
| 2 | use onestore::op::{Op, PageOp}; | ||
| 2 | use onestore::{ | 3 | use onestore::{ |
| 3 | ExGuid, RevisionIndex, Store, | 4 | ExGuid, RevisionIndex, Store, |
| 4 | document::{Document, Kind}, | 5 | document::{Document, Kind}, |
| 5 | page::{Page, PageObject, Paragraph, text::Edit}, | 6 | page::{PageObject, Paragraph}, |
| 6 | }; | 7 | }; |
| 7 | use std::{ | 8 | use std::{ |
| 8 | io::{self, BufRead, Write}, | 9 | io::{self, BufRead, Write}, |
| ... | @@ -86,34 +87,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -86,34 +87,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 86 | let mut ids = Vec::new(); | 87 | let mut ids = Vec::new(); |
| 87 | let mut font_size = None; | 88 | let mut font_size = None; |
| 88 | for pending in cache.pending()? { | 89 | for pending in cache.pending()? { |
| 89 | let Operation::Page(intent) = &pending.operation else { | 90 | let [Op::Page { op, .. }] = &pending.edit.ops[..] else { |
| 90 | panic!("Unexpected operation for this fixture") | 91 | panic!("Unexpected edit for this fixture") |
| 91 | }; | 92 | }; |
| 92 | let operation = match operation_kind.as_str() { | 93 | let operation = match (operation_kind.as_str(), op) { |
| 93 | "text" => { | 94 | ("text", PageOp::Text { text, range, with }) => { |
| 94 | let (object, before, range, replacement) = | 95 | assert_eq!(*text, target); |
| 95 | intent.text_change().expect("one replaced paragraph"); | 96 | assert_eq!(*range, 0..u32::try_from(expected.encode_utf16().count())?); |
| 96 | assert_eq!(object, target); | 97 | let operation: u64 = with.split_once(':').unwrap().0.parse()?; |
| 97 | assert_eq!(before, expected); | ||
| 98 | assert_eq!(range, 0..u32::try_from(expected.encode_utf16().count())?); | ||
| 99 | let operation: u64 = replacement.split_once(':').unwrap().0.parse()?; | ||
| 100 | expected = payload(operation, size); | 98 | expected = payload(operation, size); |
| 101 | assert_eq!(replacement, expected); | 99 | assert_eq!(*with, expected); |
| 102 | operation | 100 | operation |
| 103 | } | 101 | } |
| 104 | "insert" => { | 102 | ( |
| 105 | let added: Vec<_> = intent | 103 | "insert", |
| 106 | .after | 104 | PageOp::Add { |
| 107 | .objects | 105 | object: PageObject::Outline(outline), |
| 108 | .iter() | 106 | .. |
| 109 | .filter(|object| { | 107 | }, |
| 110 | !intent.before.objects.iter().any(|o| o.id() == object.id()) | 108 | ) => { |
| 111 | }) | ||
| 112 | .collect(); | ||
| 113 | // A save that replaced a pending one carries its outlines too. | ||
| 114 | let Some(PageObject::Outline(outline)) = added.last() else { | ||
| 115 | panic!("Expected an added outline") | ||
| 116 | }; | ||
| 117 | let text = outline.paragraphs[0].text().unwrap().text.text(); | 109 | let text = outline.paragraphs[0].text().unwrap().text.text(); |
| 118 | let operation: u64 = text.split_once(':').unwrap().0.parse()?; | 110 | let operation: u64 = text.split_once(':').unwrap().0.parse()?; |
| 119 | assert_eq!(text, payload(operation, size)); | 111 | assert_eq!(text, payload(operation, size)); |
| ... | @@ -124,33 +116,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -124,33 +116,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 124 | assert!(revision.nodes.contains_key(&outline.id)); | 116 | assert!(revision.nodes.contains_key(&outline.id)); |
| 125 | operation | 117 | operation |
| 126 | } | 118 | } |
| 127 | "format" => { | 119 | ("format", PageOp::Format { text, set, .. }) => { |
| 128 | let paragraph = intent | 120 | assert_eq!(*text, target); |
| 129 | .after | 121 | let [onestore::TextAttribute::FontSize(value)] = set[..] else { |
| 130 | .objects | 122 | panic!("Expected a font size") |
| 131 | .iter() | 123 | }; |
| 132 | .find_map(|object| match object { | ||
| 133 | PageObject::Outline(outline) => outline | ||
| 134 | .paragraphs | ||
| 135 | .iter() | ||
| 136 | .find(|p| p.text().is_some_and(|t| t.id == target)), | ||
| 137 | _ => None, | ||
| 138 | }) | ||
| 139 | .expect("formatted paragraph"); | ||
| 140 | let text = paragraph.text().unwrap(); | ||
| 141 | assert_eq!(text.text.text(), expected); | ||
| 142 | let value = text.text.spans()[0].format.font_size.expect("font size"); | ||
| 143 | assert!( | ||
| 144 | text.text | ||
| 145 | .spans() | ||
| 146 | .iter() | ||
| 147 | .all(|s| s.format.font_size == Some(value)) | ||
| 148 | ); | ||
| 149 | assert!((7.0..=130.0).contains(&value) && value.fract() == 0.0); | 124 | assert!((7.0..=130.0).contains(&value) && value.fract() == 0.0); |
| 150 | font_size = Some(value); | 125 | font_size = Some(value); |
| 151 | value as u64 - 6 | 126 | value as u64 - 6 |
| 152 | } | 127 | } |
| 153 | _ => unreachable!(), | 128 | other => panic!("Unexpected op {other:?}"), |
| 154 | }; | 129 | }; |
| 155 | assert!(operations.last().is_none_or(|last| *last < operation)); | 130 | assert!(operations.last().is_none_or(|last| *last < operation)); |
| 156 | assert!(ids.last().is_none_or(|last| *last < pending.id)); | 131 | assert!(ids.last().is_none_or(|last| *last < pending.id)); |
| ... | @@ -167,10 +142,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -167,10 +142,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 167 | ); | 142 | ); |
| 168 | } | 143 | } |
| 169 | if operations.is_empty() { | 144 | if operations.is_empty() { |
| 170 | assert!( | 145 | assert!(snapshot == base, "An empty local queue changed its image"); |
| 171 | snapshot == base, | ||
| 172 | "An empty local queue changed its working image" | ||
| 173 | ); | ||
| 174 | } | 146 | } |
| 175 | if let Some(output) = args.get(3) { | 147 | if let Some(output) = args.get(3) { |
| 176 | std::fs::write(output, &snapshot)?; | 148 | std::fs::write(output, &snapshot)?; |
| ... | @@ -197,51 +169,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -197,51 +169,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 197 | let replacement = payload(operation, size); | 169 | let replacement = payload(operation, size); |
| 198 | println!("editing {operation}"); | 170 | println!("editing {operation}"); |
| 199 | io::stdout().flush()?; | 171 | io::stdout().flush()?; |
| 200 | let (space, page) = { | 172 | let end = u32::try_from(text.encode_utf16().count())?; |
| 201 | let store = Store::parse(&source)?; | 173 | let op = match operation_kind.as_str() { |
| 202 | let index = RevisionIndex::parse(&store)?; | 174 | "text" => PageOp::Text { |
| 203 | let document = Document::parse(&index)?; | 175 | text: oid, |
| 204 | let mut page = Page::from_space(&document, sid)?; | 176 | range: 0..end, |
| 205 | fn target_paragraph(page: &mut Page, oid: ExGuid) -> &mut onestore::page::PageParagraph { | 177 | with: replacement.clone(), |
| 206 | page.objects | 178 | }, |
| 207 | .iter_mut() | 179 | "insert" => { |
| 180 | let page = cache.page(sid)?; | ||
| 181 | let template = page | ||
| 182 | .objects | ||
| 183 | .iter() | ||
| 208 | .find_map(|object| match object { | 184 | .find_map(|object| match object { |
| 209 | PageObject::Outline(outline) => outline | 185 | PageObject::Outline(outline) => outline |
| 210 | .paragraphs | 186 | .paragraphs |
| 211 | .iter_mut() | 187 | .iter() |
| 212 | .find(|p| p.text().is_some_and(|t| t.id == oid)), | 188 | .find(|p| p.text().is_some_and(|t| t.id == oid)), |
| 213 | _ => None, | 189 | _ => None, |
| 214 | }) | 190 | }) |
| 215 | .expect("target paragraph") | 191 | .expect("target paragraph") |
| 216 | } | 192 | .clone(); |
| 217 | let end = u32::try_from(text.encode_utf16().count())?; | 193 | let mut fresh = template.clone(); |
| 218 | match operation_kind.as_str() { | 194 | fresh.id = onestore::page::text::new_id()?; |
| 219 | "text" => { | 195 | fresh.parent = None; |
| 220 | let target = target_paragraph(&mut page, oid).text_mut().unwrap(); | 196 | fresh.level = 1; |
| 221 | let format = target.text.format_at(0)?.clone(); | 197 | fresh.lists.clear(); |
| 222 | target.text.apply(Edit { | 198 | fresh.tags.clear(); |
| 223 | range: 0..end, | 199 | fresh.style = None; |
| 224 | replacement: Paragraph::new(replacement.clone(), format), | 200 | let format = template.text().unwrap().text.format_at(0)?.clone(); |
| 225 | })?; | 201 | fresh.content = onestore::page::ParagraphContent::Text(onestore::page::TextObject { |
| 226 | } | 202 | id: onestore::page::text::new_id()?, |
| 227 | "insert" => { | 203 | date_field: None, |
| 228 | let template = target_paragraph(&mut page, oid).clone(); | 204 | text: Paragraph::new(replacement.clone(), format), |
| 229 | let mut fresh = template.clone(); | 205 | tags: Vec::new(), |
| 230 | fresh.id = onestore::page::text::new_id()?; | 206 | }); |
| 231 | fresh.parent = None; | 207 | PageOp::Add { |
| 232 | fresh.level = 1; | 208 | object: PageObject::Outline(onestore::page::Outline { |
| 233 | fresh.lists.clear(); | ||
| 234 | fresh.tags.clear(); | ||
| 235 | fresh.style = None; | ||
| 236 | let format = template.text().unwrap().text.format_at(0)?.clone(); | ||
| 237 | fresh.content = | ||
| 238 | onestore::page::ParagraphContent::Text(onestore::page::TextObject { | ||
| 239 | id: onestore::page::text::new_id()?, | ||
| 240 | date_field: None, | ||
| 241 | text: Paragraph::new(replacement.clone(), format), | ||
| 242 | tags: Vec::new(), | ||
| 243 | }); | ||
| 244 | let outline = onestore::page::Outline { | ||
| 245 | id: onestore::page::text::new_id()?, | 209 | id: onestore::page::text::new_id()?, |
| 246 | title: false, | 210 | title: false, |
| 247 | min_width: None, | 211 | min_width: None, |
| ... | @@ -253,29 +217,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -253,29 +217,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 253 | indents: Vec::new(), | 217 | indents: Vec::new(), |
| 254 | paragraphs: vec![fresh], | 218 | paragraphs: vec![fresh], |
| 255 | unsupported: Vec::new(), | 219 | unsupported: Vec::new(), |
| 256 | }; | 220 | }), |
| 257 | let at = page | 221 | before: page |
| 258 | .objects | 222 | .objects |
| 259 | .iter() | 223 | .iter() |
| 260 | .position(|o| matches!(o, PageObject::Title(_))) | 224 | .find(|o| matches!(o, PageObject::Title(_))) |
| 261 | .unwrap_or(page.objects.len()); | 225 | .map(PageObject::id), |
| 262 | page.objects.insert(at, PageObject::Outline(outline)); | ||
| 263 | } | 226 | } |
| 264 | "format" => { | 227 | } |
| 265 | assert!((1..=124).contains(&operation)); | 228 | "format" => { |
| 266 | let target = target_paragraph(&mut page, oid).text_mut().unwrap(); | 229 | assert!((1..=124).contains(&operation)); |
| 267 | let mut format = target.text.format_at(0)?.clone(); | 230 | PageOp::Format { |
| 268 | format.font_size = Some(6.0 + operation as f32); | 231 | text: oid, |
| 269 | target.text.apply(Edit { | 232 | range: 0..end, |
| 270 | range: 0..end, | 233 | set: vec![onestore::TextAttribute::FontSize(6.0 + operation as f32)], |
| 271 | replacement: Paragraph::new(text.clone(), format), | 234 | clear: Vec::new(), |
| 272 | })?; | ||
| 273 | } | 235 | } |
| 274 | _ => unreachable!(), | ||
| 275 | } | 236 | } |
| 276 | (sid, page) | 237 | _ => unreachable!(), |
| 277 | }; | 238 | }; |
| 278 | let id = cache.save(&source, space, &page, "Fixture")?.unwrap(); | 239 | let id = cache.apply( |
| 240 | "Fixture", | ||
| 241 | onestore::op::Edit { | ||
| 242 | at: 133_000_000_000_000_000, | ||
| 243 | ops: vec![Op::Page { space: sid, op }], | ||
| 244 | }, | ||
| 245 | )?; | ||
| 279 | if acknowledgement == "unack" { | 246 | if acknowledgement == "unack" { |
| 280 | println!("durable {operation} {id}"); | 247 | println!("durable {operation} {id}"); |
| 281 | } else { | 248 | } else { |
crates/notebook/examples/keystroke_probe.rs created+260| ... | @@ -0,0 +1,260 @@ | ||
| 1 | //! `keystroke_probe PARAGRAPHS KEYSTROKES`: types into the middle paragraph of a page | ||
| 2 | //! of PARAGRAPHS paragraphs and reports, per keystroke, the time, the bytes the cache | ||
| 3 | //! writes to disk and the bytes the section file sees: queued alone, then each one | ||
| 4 | //! published at once, then idle polls. The section file stays in memory so the | ||
| 5 | //! process's disk writes (macOS `proc_pid_rusage`) are the cache's. | ||
| 6 | |||
| 7 | use notebook::{Remote, Replica}; | ||
| 8 | use 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 | }; | ||
| 17 | use std::{ | ||
| 18 | io, | ||
| 19 | time::{Duration, Instant}, | ||
| 20 | }; | ||
| 21 | |||
| 22 | fn 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. | ||
| 37 | struct Memory { | ||
| 38 | bytes: Vec<u8>, | ||
| 39 | read: u64, | ||
| 40 | written: u64, | ||
| 41 | } | ||
| 42 | |||
| 43 | impl 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 | |||
| 64 | impl 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 | |||
| 81 | fn 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 | |||
| 105 | fn 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 | |||
| 122 | fn 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. | ||
| 3 | use notebook::{Remote, Replica}; | ||
| 4 | use onestore::{CommitError, CommitIo, Stamp, Transaction}; | ||
| 5 | use std::io; | ||
| 6 | |||
| 7 | struct Memory(Vec<u8>); | ||
| 8 | |||
| 9 | impl 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 | |||
| 27 | impl 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 | |||
| 42 | fn 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 | |||
| 50 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 51 | for path in std::env::args().skip(1) { | ||
| 52 | let copy = std::env::temp_dir().join(format!( | ||
| 53 | "migrate-probe-{}-{}", | ||
| 54 | std::process::id(), | ||
| 55 | std::path::Path::new(&path) | ||
| 56 | .file_name() | ||
| 57 | .unwrap() | ||
| 58 | .to_string_lossy() | ||
| 59 | )); | ||
| 60 | std::fs::copy(&path, &copy)?; | ||
| 61 | // A cache open or crashed in WAL mode keeps committed pages in its `-wal` file. | ||
| 62 | let wal = format!("{path}-wal"); | ||
| 63 | if std::path::Path::new(&wal).exists() { | ||
| 64 | let mut target = copy.as_os_str().to_owned(); | ||
| 65 | target.push("-wal"); | ||
| 66 | std::fs::copy(&wal, target)?; | ||
| 67 | } | ||
| 68 | let started = std::time::Instant::now(); | ||
| 69 | match Replica::open(&copy) { | ||
| 70 | Ok(replica) => { | ||
| 71 | let elapsed = started.elapsed(); | ||
| 72 | let pending = replica.pending()?; | ||
| 73 | let summary = replica.recovery_summary()?; | ||
| 74 | println!( | ||
| 75 | "{path}: converted in {:.1} ms; {} edits; {:?}; conflict {:?}", | ||
| 76 | elapsed.as_secs_f64() * 1e3, | ||
| 77 | pending.len(), | ||
| 78 | summary, | ||
| 79 | replica.conflict()? | ||
| 80 | ); | ||
| 81 | for edit in &pending { | ||
| 82 | println!( | ||
| 83 | " {} {:?} {} ops", | ||
| 84 | edit.id, | ||
| 85 | replica.status(edit.id)?, | ||
| 86 | edit.edit.ops.len() | ||
| 87 | ); | ||
| 88 | } | ||
| 89 | for (space, title, level) in replica.pages()? { | ||
| 90 | println!(" page {space} {level} {title:?}"); | ||
| 91 | } | ||
| 92 | let blocked = replica.conflict()?.is_some() | ||
| 93 | || replica.recovery_summary()?.uncertain_edits > 0; | ||
| 94 | if !pending.is_empty() && !blocked { | ||
| 95 | let local = pages(&replica)?; | ||
| 96 | let mut remote = Memory(replica.remote_snapshot()?); | ||
| 97 | let synced = replica.sync_once(&mut remote)?; | ||
| 98 | let published = { | ||
| 99 | let arena = onestore::Arena::default(); | ||
| 100 | let mut section = onestore::Section::open(&arena, remote.0.clone())?; | ||
| 101 | section | ||
| 102 | .pages()? | ||
| 103 | .into_iter() | ||
| 104 | .map(|(space, ..)| section.page(space)) | ||
| 105 | .collect::<Result<Vec<_>, _>>()? | ||
| 106 | }; | ||
| 107 | println!( | ||
| 108 | " published {:?}; remote pages equal the converted pages: {}", | ||
| 109 | synced.edit.map(|(id, _)| id), | ||
| 110 | published == local | ||
| 111 | ); | ||
| 112 | } | ||
| 113 | } | ||
| 114 | Err(error) => println!("{path}: {error}"), | ||
| 115 | } | ||
| 116 | } | ||
| 117 | Ok(()) | ||
| 118 | } | ||
crates/notebook/examples/queue_scale.rs+25-18| ... | @@ -47,13 +47,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -47,13 +47,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 47 | let start = Instant::now(); | 47 | let start = Instant::now(); |
| 48 | for n in 0..count { | 48 | for n in 0..count { |
| 49 | let edit_start = Instant::now(); | 49 | let edit_start = Instant::now(); |
| 50 | let source = cache.snapshot()?; | ||
| 51 | let store = Store::parse(&source)?; | ||
| 52 | let index = RevisionIndex::parse(&store)?; | ||
| 53 | let document = Document::parse(&index)?; | ||
| 54 | let slot = n % 2; | 50 | let slot = n % 2; |
| 55 | let space = pages[slot].0; | 51 | let space = pages[slot].0; |
| 56 | let mut page = Page::from_space(&document, space)?; | 52 | let mut page = cache.page(space)?; |
| 57 | let text = page | 53 | let text = page |
| 58 | .objects | 54 | .objects |
| 59 | .iter_mut() | 55 | .iter_mut() |
| ... | @@ -76,13 +72,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -76,13 +72,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 76 | range: 0..end, | 72 | range: 0..end, |
| 77 | replacement: Paragraph::new(expected[slot].clone(), format), | 73 | replacement: Paragraph::new(expected[slot].clone(), format), |
| 78 | })?; | 74 | })?; |
| 79 | let id = cache.save(&source, space, &page, "Fixture")?.unwrap(); | 75 | let before = cache.page(space)?; |
| 76 | let ops = onestore::op::lower_page(&before, &page)?; | ||
| 77 | let id = cache.apply( | ||
| 78 | "Fixture", | ||
| 79 | onestore::op::Edit { | ||
| 80 | at: 133_000_000_000_000_000, | ||
| 81 | ops: ops | ||
| 82 | .into_iter() | ||
| 83 | .map(|op| onestore::op::Op::Page { space, op }) | ||
| 84 | .collect(), | ||
| 85 | }, | ||
| 86 | )?; | ||
| 80 | assert!(ids.last().is_none_or(|previous| *previous < id)); | 87 | assert!(ids.last().is_none_or(|previous| *previous < id)); |
| 81 | ids.push(id); | 88 | ids.push(id); |
| 82 | println!( | 89 | println!( |
| 83 | "{}", | 90 | "{}", |
| 84 | serde_json::json!({"phase":"ack","n":n,"id":id, | 91 | serde_json::json!({"phase":"ack","n":n,"id":id, |
| 85 | "ms":edit_start.elapsed().as_secs_f64()*1000.0,"working_bytes":source.len()}) | 92 | "ms":edit_start.elapsed().as_secs_f64()*1000.0}) |
| 86 | ); | 93 | ); |
| 87 | } | 94 | } |
| 88 | println!( | 95 | println!( |
| ... | @@ -114,16 +121,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -114,16 +121,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 114 | serde_json::json!({"phase":"export","seconds":start.elapsed().as_secs_f64()}) | 121 | serde_json::json!({"phase":"export","seconds":start.elapsed().as_secs_f64()}) |
| 115 | ); | 122 | ); |
| 116 | let start = Instant::now(); | 123 | let start = Instant::now(); |
| 117 | for (n, id) in ids.iter().enumerate() { | 124 | // The queue publishes as one batch. |
| 118 | let step = Instant::now(); | 125 | let step = Instant::now(); |
| 119 | assert!( | 126 | assert!(matches!( |
| 120 | matches!(cache.sync_once(&mut remote)?, Some((actual, EditStatus::Published { .. })) if actual == *id) | 127 | cache.sync_once(&mut remote)?.edit, |
| 121 | ); | 128 | Some((actual, EditStatus::Published { .. })) if Some(&actual) == ids.last() |
| 122 | println!( | 129 | )); |
| 123 | "{}", | 130 | println!( |
| 124 | serde_json::json!({"phase":"publish","n":n,"id":id,"ms":step.elapsed().as_secs_f64()*1000.0}) | 131 | "{}", |
| 125 | ); | 132 | serde_json::json!({"phase":"publish","count":ids.len(),"ms":step.elapsed().as_secs_f64()*1000.0}) |
| 126 | } | 133 | ); |
| 127 | assert!(cache.pending()?.is_empty()); | 134 | assert!(cache.pending()?.is_empty()); |
| 128 | let published = cache.snapshot()?; | 135 | let published = cache.snapshot()?; |
| 129 | assert_eq!(onestore::read_file(&remote.0)?, published); | 136 | assert_eq!(onestore::read_file(&remote.0)?, published); |
| ... | @@ -145,7 +152,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -145,7 +152,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 145 | .collect::<Vec<_>>(), | 152 | .collect::<Vec<_>>(), |
| 146 | ids | 153 | ids |
| 147 | ); | 154 | ); |
| 148 | assert_eq!(archive.snapshot()?, source); | 155 | verify(&archive.snapshot()?, &pages, &expected)?; |
| 149 | println!( | 156 | println!( |
| 150 | "{}", | 157 | "{}", |
| 151 | serde_json::json!({"phase":"complete","count":count, | 158 | serde_json::json!({"phase":"complete","count":count, |
crates/notebook/examples/recovery_probe.rs+15-33| ... | @@ -98,12 +98,10 @@ fn report(cache: &Replica, root: &Path) -> Result<(), Box<dyn std::error::Error> | ... | @@ -98,12 +98,10 @@ fn report(cache: &Replica, root: &Path) -> Result<(), Box<dyn std::error::Error> |
| 98 | "{}", | 98 | "{}", |
| 99 | json!({"event":"state", "status":status, "revision":revision, | 99 | json!({"event":"state", "status":status, "revision":revision, |
| 100 | "local_text":local.text, "remote_text":remote.text, "remote_revision":remote.revision.to_string(), | 100 | "local_text":local.text, "remote_text":remote.text, "remote_revision":remote.revision.to_string(), |
| 101 | "pending":cache.pending()?.iter().map(|pending|match &pending.operation { | 101 | "pending":cache.pending()?.iter().map(|pending| match &pending.edit.ops[..] { |
| 102 | notebook::Operation::Page(intent) => match intent.text_change() { | 102 | [onestore::op::Op::Page { op: onestore::op::PageOp::Text { range, with, .. }, .. }] => |
| 103 | Some((_, before, range, replacement)) => json!({"id":pending.id,"before":before,"replacement":replacement,"range":[range.start,range.end]}), | 103 | json!({"id":pending.id,"replacement":with,"range":[range.start,range.end]}), |
| 104 | None => json!({"id":pending.id,"operation":"page"}), | 104 | _ => json!({"id":pending.id,"edit":pending.edit}), |
| 105 | }, | ||
| 106 | operation => json!({"id":pending.id,"operation":operation}), | ||
| 107 | }).collect::<Vec<_>>() }) | 105 | }).collect::<Vec<_>>() }) |
| 108 | ); | 106 | ); |
| 109 | Ok(()) | 107 | Ok(()) |
| ... | @@ -128,34 +126,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -128,34 +126,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 128 | drop(remote); | 126 | drop(remote); |
| 129 | let cache = Replica::create(root.join("cache.sqlite"), &source)?; | 127 | let cache = Replica::create(root.join("cache.sqlite"), &source)?; |
| 130 | let at = u32::try_from(target.text.encode_utf16().count())?; | 128 | let at = u32::try_from(target.text.encode_utf16().count())?; |
| 131 | let mut page = { | 129 | let edit = onestore::op::Edit { |
| 132 | let store = onestore::Store::parse(&source)?; | 130 | at: 133_000_000_000_000_000, |
| 133 | let index = onestore::RevisionIndex::parse(&store)?; | 131 | ops: vec![onestore::op::Op::Page { |
| 134 | let document = onestore::document::Document::parse(&index)?; | 132 | space: target.space, |
| 135 | onestore::page::Page::from_space(&document, target.space)? | 133 | op: onestore::op::PageOp::Text { |
| 134 | text: target.object, | ||
| 135 | range: at..at, | ||
| 136 | with: " [offline-recovery]".into(), | ||
| 137 | }, | ||
| 138 | }], | ||
| 136 | }; | 139 | }; |
| 137 | let paragraph = page | 140 | assert_eq!(cache.apply("Fixture", edit)?, 1); |
| 138 | .objects | ||
| 139 | .iter_mut() | ||
| 140 | .find_map(|object| match object { | ||
| 141 | onestore::page::PageObject::Outline(outline) => outline | ||
| 142 | .paragraphs | ||
| 143 | .iter_mut() | ||
| 144 | .find(|p| p.text().is_some_and(|t| t.id == target.object)), | ||
| 145 | _ => None, | ||
| 146 | }) | ||
| 147 | .ok_or("Target paragraph is not on its page")? | ||
| 148 | .text_mut() | ||
| 149 | .unwrap(); | ||
| 150 | let format = paragraph.text.format_at(at)?.clone(); | ||
| 151 | paragraph.text.apply(onestore::page::text::Edit { | ||
| 152 | range: at..at, | ||
| 153 | replacement: onestore::page::Paragraph::new(" [offline-recovery]".into(), format), | ||
| 154 | })?; | ||
| 155 | assert_eq!( | ||
| 156 | cache.save(&source, target.space, &page, "Fixture")?, | ||
| 157 | Some(1) | ||
| 158 | ); | ||
| 159 | phase("local-after"); | 141 | phase("local-after"); |
| 160 | report(&cache, root)?; | 142 | report(&cache, root)?; |
| 161 | } else if args.len() == 2 && ["inspect", "sync"].contains(&mode.as_str()) { | 143 | } else if args.len() == 2 && ["inspect", "sync"].contains(&mode.as_str()) { |
crates/notebook/examples/session_client.rs+13-4| ... | @@ -159,9 +159,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -159,9 +159,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 159 | for event in section.events() { | 159 | for event in section.events() { |
| 160 | match event { | 160 | match event { |
| 161 | Event::Attempt { | 161 | Event::Attempt { |
| 162 | id, | 162 | status: EditStatus::Published { .. }, |
| 163 | status: EditStatus::Published { revision }, | 163 | .. |
| 164 | } => receipts.push((id, revision)), | 164 | } => {} |
| 165 | Event::Attempt { id, status } => { | 165 | Event::Attempt { id, status } => { |
| 166 | println!( | 166 | println!( |
| 167 | "{}", | 167 | "{}", |
| ... | @@ -175,7 +175,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -175,7 +175,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 175 | ); | 175 | ); |
| 176 | } | 176 | } |
| 177 | Event::Failed(error) => return Err(error.into()), | 177 | Event::Failed(error) => return Err(error.into()), |
| 178 | Event::Refreshed => {} | 178 | Event::Rejected { error, .. } => return Err(error.into()), |
| 179 | Event::Refreshed | Event::Changed(_) => {} | ||
| 180 | } | ||
| 181 | } | ||
| 182 | // A batch reports its newest edit; each edit's receipt is its own. | ||
| 183 | for (id, ..) in &queued { | ||
| 184 | if !receipts.iter().any(|(n, _)| n == id) | ||
| 185 | && let Some(EditStatus::Published { revision }) = section.status(*id)? | ||
| 186 | { | ||
| 187 | receipts.push((*id, revision)); | ||
| 179 | } | 188 | } |
| 180 | } | 189 | } |
| 181 | } | 190 | } |
crates/notebook/examples/smb_offline_client.rs+52-195| ... | @@ -3,9 +3,7 @@ mod concurrent; | ... | @@ -3,9 +3,7 @@ mod concurrent; |
| 3 | 3 | ||
| 4 | use notebook::smb::{Client, Credentials}; | 4 | use notebook::smb::{Client, Credentials}; |
| 5 | use notebook::{EditStatus, Error, Remote, Replica, SmbRemote}; | 5 | use notebook::{EditStatus, Error, Remote, Replica, SmbRemote}; |
| 6 | use onestore::{ | 6 | use onestore::{CommitError, CommitState, ExGuid, RevisionIndex, Store, Transaction}; |
| 7 | CommitError, CommitState, ExGuid, RevisionIndex, Store, Transaction, page::Paragraph, | ||
| 8 | }; | ||
| 9 | use serde_json::json; | 7 | use serde_json::json; |
| 10 | use std::{ | 8 | use std::{ |
| 11 | env, | 9 | env, |
| ... | @@ -252,91 +250,19 @@ impl<R: Remote> Remote for Traced<R> { | ... | @@ -252,91 +250,19 @@ impl<R: Remote> Remote for Traced<R> { |
| 252 | } | 250 | } |
| 253 | } | 251 | } |
| 254 | 252 | ||
| 255 | fn tokens(text: &str) -> Option<Vec<&str>> { | 253 | /// The edit appending `token` at `at` of a text. |
| 256 | let mut remaining = text.strip_prefix("Concurrent edits:")?; | 254 | fn append(space: ExGuid, text: ExGuid, at: u32, token: &str) -> onestore::op::Edit { |
| 257 | let mut tokens = Vec::new(); | 255 | onestore::op::Edit { |
| 258 | while !remaining.is_empty() { | 256 | at: u64::try_from(now()).unwrap_or_default() * 10 + 116_444_736_000_000_000, |
| 259 | let body = remaining.strip_prefix(" [w")?; | 257 | ops: vec![onestore::op::Op::Page { |
| 260 | let (token, tail) = body.split_once(']')?; | 258 | space, |
| 261 | let (actor, operation) = token.split_once(':')?; | 259 | op: onestore::op::PageOp::Text { |
| 262 | if actor.is_empty() | 260 | text, |
| 263 | || operation.is_empty() | 261 | range: at..at, |
| 264 | || !actor | 262 | with: token.to_owned(), |
| 265 | .bytes() | 263 | }, |
| 266 | .chain(operation.bytes()) | 264 | }], |
| 267 | .all(|b| b.is_ascii_digit()) | ||
| 268 | || tokens.contains(&token) | ||
| 269 | { | ||
| 270 | return None; | ||
| 271 | } | ||
| 272 | tokens.push(token); | ||
| 273 | remaining = tail; | ||
| 274 | } | ||
| 275 | Some(tokens) | ||
| 276 | } | ||
| 277 | |||
| 278 | // This owned workload explicitly resolves append conflicts after all retained tokens. | ||
| 279 | fn 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 | |||
| 294 | fn 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 | |||
| 317 | fn 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 | |||
| 329 | fn append_position(before: &str, current: &str, token: &str) -> Option<u32> { | ||
| 330 | let original = tokens(before)?; | ||
| 331 | let present = tokens(current)?; | ||
| 332 | let mut retained = present.iter(); | ||
| 333 | for token in original { | ||
| 334 | retained.find(|&&candidate| candidate == token)?; | ||
| 335 | } | 265 | } |
| 336 | if current.contains(token) { | ||
| 337 | return None; | ||
| 338 | } | ||
| 339 | u32::try_from(current.encode_utf16().count()).ok() | ||
| 340 | } | 266 | } |
| 341 | 267 | ||
| 342 | fn main() -> Result<(), Box<dyn std::error::Error>> { | 268 | fn main() -> Result<(), Box<dyn std::error::Error>> { |
| ... | @@ -459,10 +385,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -459,10 +385,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 459 | let at = u32::try_from(target.text.encode_utf16().count())?; | 385 | let at = u32::try_from(target.text.encode_utf16().count())?; |
| 460 | let token = format!(" [{}:{}]", args[2], generated); | 386 | let token = format!(" [{}:{}]", args[2], generated); |
| 461 | let started = now(); | 387 | let started = now(); |
| 462 | let mut page = page_with(&source, target.space, target.object)?; | 388 | match cache.apply( |
| 463 | append_to(&mut page, target.object, at, &token); | 389 | "Offline document writer", |
| 464 | match cache.save(&source, target.space, &page, "Offline document writer") { | 390 | append(target.space, target.object, at, &token), |
| 465 | Ok(Some(id)) => { | 391 | ) { |
| 392 | Ok(id) => { | ||
| 466 | println!( | 393 | println!( |
| 467 | "{}", | 394 | "{}", |
| 468 | json!({"event":"local_commit", "id":id, "operation":generated, "space":target.space.to_string(), "object":target.object.to_string(), "before":target.text, "token":token, "started_us":started, "finished_us":now()}) | 395 | json!({"event":"local_commit", "id":id, "operation":generated, "space":target.space.to_string(), "object":target.object.to_string(), "before":target.text, "token":token, "started_us":started, "finished_us":now()}) |
| ... | @@ -470,43 +397,44 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -470,43 +397,44 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 470 | ids.push(id); | 397 | ids.push(id); |
| 471 | generated += 1; | 398 | generated += 1; |
| 472 | } | 399 | } |
| 473 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {} | ||
| 474 | other => return Err(format!("Unexpected local result: {other:?}").into()), | 400 | other => return Err(format!("Unexpected local result: {other:?}").into()), |
| 475 | } | 401 | } |
| 476 | } | 402 | } |
| 477 | if let Some(intent) = pending.first() | 403 | if let Some(conflict) = cache.conflict()? { |
| 478 | && matches!(cache.status(intent.id)?, Some(EditStatus::Conflict(_))) | 404 | // Concurrent appends meet at the end of the text: take the remote text and |
| 479 | { | 405 | // append the queued tokens it lacks after it again. |
| 480 | let local = cache.snapshot()?; | 406 | let remote = view(&cache.remote_snapshot()?)?; |
| 481 | let remote = cache.remote_snapshot()?; | 407 | let tokens: Vec<String> = cache |
| 482 | let current = view(&remote)?; | 408 | .pending()? |
| 483 | let notebook::Operation::Page(edit) = &intent.operation else { | 409 | .iter() |
| 484 | return Err(format!( | 410 | .flat_map(|queued| queued.edit.ops.iter()) |
| 485 | "Intent {} requires review: {:?}", | 411 | .filter_map(|op| match op { |
| 486 | intent.id, | 412 | onestore::op::Op::Page { |
| 487 | cache.status(intent.id)? | 413 | op: onestore::op::PageOp::Text { with, .. }, |
| 488 | ) | 414 | .. |
| 489 | .into()); | 415 | } => Some(with.clone()), |
| 490 | }; | 416 | _ => None, |
| 491 | let (object, before, _, token) = edit | 417 | }) |
| 492 | .text_change() | 418 | .filter(|token| !remote.text.contains(token.as_str())) |
| 493 | .ok_or("A review expects one appended token")?; | 419 | .collect(); |
| 494 | let at = append_position(&before, &current.text, &token) | 420 | match cache.resolve(conflict.id, notebook::Resolution::Theirs) { |
| 495 | .ok_or("Append model disagrees with retained history")?; | 421 | Ok(()) => { |
| 496 | let mut reviewed = page_with(&remote, intent.space, object)?; | 422 | let mut at = u32::try_from(remote.text.encode_utf16().count())?; |
| 497 | append_to(&mut reviewed, object, at, &token); | 423 | for token in &tokens { |
| 498 | match cache.review_page(intent.id, &local, &remote, &reviewed) { | 424 | cache.apply( |
| 499 | Ok(()) => println!( | 425 | "Offline document writer", |
| 500 | "{}", | 426 | append(remote.space, remote.object, at, token), |
| 501 | json!({"event":"reviewed_append", "id":intent.id, "before":before, "remote":current.text, "token":token, "at_us":now()}) | 427 | )?; |
| 502 | ), | 428 | at += u32::try_from(token.encode_utf16().count())?; |
| 429 | } | ||
| 430 | println!( | ||
| 431 | "{}", | ||
| 432 | json!({"event":"reviewed_append", "id":conflict.id, "remote":remote.text, "tokens":tokens, "at_us":now()}) | ||
| 433 | ); | ||
| 434 | } | ||
| 503 | Err(Error::Io(error)) | 435 | Err(Error::Io(error)) |
| 504 | if [ | 436 | if [io::ErrorKind::WouldBlock, io::ErrorKind::InvalidInput] |
| 505 | io::ErrorKind::ResourceBusy, | 437 | .contains(&error.kind()) => {} |
| 506 | io::ErrorKind::WouldBlock, | ||
| 507 | io::ErrorKind::InvalidInput, | ||
| 508 | ] | ||
| 509 | .contains(&error.kind()) => {} | ||
| 510 | Err(error) => return Err(error.into()), | 438 | Err(error) => return Err(error.into()), |
| 511 | } | 439 | } |
| 512 | } | 440 | } |
| ... | @@ -546,74 +474,3 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -546,74 +474,3 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 546 | ); | 474 | ); |
| 547 | Ok(()) | 475 | Ok(()) |
| 548 | } | 476 | } |
| 549 | |||
| 550 | #[test] | ||
| 551 | fn 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)] | ||
| 582 | mod tests { | ||
| 583 | use super::*; | ||
| 584 | |||
| 585 | #[test] | ||
| 586 | fn publication_differences_retain_removed_text_and_graph_identities() { | ||
| 587 | let before = DocumentView { | ||
| 588 | texts: serde_json::from_value(json!({"left":{"text":"ab"}, "right":{"text":"cd"}, "untouched":{"text":"ef"}})).unwrap(), | ||
| 589 | graph: serde_json::from_value(json!({"outline":{"children":["a","b"]}, "a":{"content":["left"]}, "b":{"content":["right"]}})).unwrap(), | ||
| 590 | }; | ||
| 591 | let after = DocumentView { | ||
| 592 | texts: serde_json::from_value( | ||
| 593 | json!({"left":{"text":"abcd"}, "untouched":{"text":"ef"}}), | ||
| 594 | ) | ||
| 595 | .unwrap(), | ||
| 596 | graph: serde_json::from_value( | ||
| 597 | json!({"outline":{"children":["a"]}, "a":{"content":["left"]}}), | ||
| 598 | ) | ||
| 599 | .unwrap(), | ||
| 600 | }; | ||
| 601 | let changes = after.changes(&before); | ||
| 602 | assert_eq!( | ||
| 603 | changes.texts, | ||
| 604 | serde_json::from_value::<serde_json::Map<_, _>>( | ||
| 605 | json!({"left":{"text":"abcd"}, "right":null}) | ||
| 606 | ) | ||
| 607 | .unwrap() | ||
| 608 | ); | ||
| 609 | assert_eq!( | ||
| 610 | changes.graph, | ||
| 611 | serde_json::from_value::<serde_json::Map<_, _>>( | ||
| 612 | json!({"outline":{"children":["a"]}, "b":null}) | ||
| 613 | ) | ||
| 614 | .unwrap() | ||
| 615 | ); | ||
| 616 | assert_eq!(before.changes(&after).texts["right"], before.texts["right"]); | ||
| 617 | assert_eq!(before.changes(&before), DocumentView::default()); | ||
| 618 | } | ||
| 619 | } |
crates/notebook/src/assets.rs+28-37| ... | @@ -1,5 +1,9 @@ | ... | @@ -1,5 +1,9 @@ |
| 1 | use super::*; | 1 | use super::*; |
| 2 | use onestore::FileDataReference; | 2 | use onestore::FileDataReference; |
| 3 | use onestore::{ | ||
| 4 | RevisionIndex, Store, | ||
| 5 | document::{Document, Kind}, | ||
| 6 | }; | ||
| 3 | use rusqlite::OptionalExtension; | 7 | use rusqlite::OptionalExtension; |
| 4 | use sha2::{Digest, Sha256}; | 8 | use sha2::{Digest, Sha256}; |
| 5 | 9 | ||
| ... | @@ -7,12 +11,7 @@ impl Replica { | ... | @@ -7,12 +11,7 @@ impl Replica { |
| 7 | /// Reads a previously downloaded external payload without network access. | 11 | /// Reads a previously downloaded external payload without network access. |
| 8 | /// Absence is distinct from an empty payload; every returned buffer passes its stored checksum. | 12 | /// Absence is distinct from an empty payload; every returned buffer passes its stored checksum. |
| 9 | pub fn cached_asset(&self, filename: &str, limit: usize) -> Result<Option<Vec<u8>>> { | 13 | pub fn cached_asset(&self, filename: &str, limit: usize) -> Result<Option<Vec<u8>>> { |
| 10 | let key = key(filename)?; | 14 | cached(&*self.lock()?, &key(filename)?, limit) |
| 11 | let connection = self | ||
| 12 | .connection | ||
| 13 | .lock() | ||
| 14 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 15 | cached(&connection, &key, limit) | ||
| 16 | } | 15 | } |
| 17 | 16 | ||
| 18 | /// Fetches a declared external payload and durably retains it without changing the edit queue. | 17 | /// Fetches a declared external payload and durably retains it without changing the edit queue. |
| ... | @@ -26,32 +25,23 @@ impl Replica { | ... | @@ -26,32 +25,23 @@ impl Replica { |
| 26 | limit: usize, | 25 | limit: usize, |
| 27 | ) -> Result<Vec<u8>> { | 26 | ) -> Result<Vec<u8>> { |
| 28 | let key = key(filename)?; | 27 | let key = key(filename)?; |
| 29 | { | 28 | if !self.references(&key)? { |
| 30 | let connection = self | 29 | return Err(io::Error::new( |
| 31 | .connection | 30 | io::ErrorKind::InvalidInput, |
| 32 | .lock() | 31 | "The retained document images do not reference this external payload", |
| 33 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | 32 | ) |
| 34 | if !referenced(&connection, &key)? { | 33 | .into()); |
| 35 | return Err(io::Error::new( | ||
| 36 | io::ErrorKind::InvalidInput, | ||
| 37 | "The retained document images do not reference this external payload", | ||
| 38 | ) | ||
| 39 | .into()); | ||
| 40 | } | ||
| 41 | } | 34 | } |
| 42 | let bytes = crate::discover::read_external_asset(source, section, filename, limit)?; | 35 | let bytes = crate::discover::read_external_asset(source, section, filename, limit)?; |
| 43 | let mut connection = self | 36 | if !self.references(&key)? { |
| 44 | .connection | ||
| 45 | .lock() | ||
| 46 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 47 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 48 | if !referenced(&transaction, &key)? { | ||
| 49 | return Err(io::Error::new( | 37 | return Err(io::Error::new( |
| 50 | io::ErrorKind::ResourceBusy, | 38 | io::ErrorKind::ResourceBusy, |
| 51 | "The external payload reference changed during download", | 39 | "The external payload reference changed during download", |
| 52 | ) | 40 | ) |
| 53 | .into()); | 41 | .into()); |
| 54 | } | 42 | } |
| 43 | let mut connection = self.lock()?; | ||
| 44 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 55 | let previous = match cached(&transaction, &key, bytes.len()) { | 45 | let previous = match cached(&transaction, &key, bytes.len()) { |
| 56 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::FileTooLarge => { | 46 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::FileTooLarge => { |
| 57 | return Err(Error::AssetChanged); | 47 | return Err(Error::AssetChanged); |
| ... | @@ -78,21 +68,22 @@ pub(crate) fn key(filename: &str) -> Result<String> { | ... | @@ -78,21 +68,22 @@ pub(crate) fn key(filename: &str) -> Result<String> { |
| 78 | Ok(filename.to_ascii_lowercase()) | 68 | Ok(filename.to_ascii_lowercase()) |
| 79 | } | 69 | } |
| 80 | 70 | ||
| 81 | fn referenced(connection: &Connection, key: &str) -> Result<bool> { | 71 | impl Replica { |
| 82 | // One image at a time: the working image usually answers. | 72 | /// Whether the local pages or the remote image declare the external payload `key`. |
| 83 | for read in [crate::images::working, crate::images::base] { | 73 | fn references(&self, key: &str) -> Result<bool> { |
| 84 | let image = read(connection)?; | 74 | for image in [self.snapshot()?, self.remote_snapshot()?] { |
| 85 | let store = Store::parse(&image)?; | 75 | let store = Store::parse(&image)?; |
| 86 | let index = RevisionIndex::parse(&store)?; | 76 | let index = RevisionIndex::parse(&store)?; |
| 87 | let document = Document::parse(&index)?; | 77 | let document = Document::parse(&index)?; |
| 88 | if document.spaces.values().flat_map(|space| space.revisions.values()) | 78 | if document.spaces.values().flat_map(|space| space.revisions.values()) |
| 89 | .flat_map(|revision| revision.nodes.values()).any(|node| { | 79 | .flat_map(|revision| revision.nodes.values()).any(|node| { |
| 90 | matches!(&node.kind, Kind::File { reference: FileDataReference::External(name), .. } if name.eq_ignore_ascii_case(key)) | 80 | matches!(&node.kind, Kind::File { reference: FileDataReference::External(name), .. } if name.eq_ignore_ascii_case(key)) |
| 91 | }) { | 81 | }) { |
| 92 | return Ok(true); | 82 | return Ok(true); |
| 83 | } | ||
| 93 | } | 84 | } |
| 85 | Ok(false) | ||
| 94 | } | 86 | } |
| 95 | Ok(false) | ||
| 96 | } | 87 | } |
| 97 | 88 | ||
| 98 | pub(crate) fn cached(connection: &Connection, key: &str, limit: usize) -> Result<Option<Vec<u8>>> { | 89 | pub(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 | |||
| 5 | use crate::Result; | ||
| 6 | use onestore::{Stamp, Transaction}; | ||
| 7 | use rusqlite::{Connection, OptionalExtension, params}; | ||
| 8 | use std::io; | ||
| 9 | |||
| 10 | const CHUNK: usize = 16 * 1024; | ||
| 11 | |||
| 12 | #[derive(Clone, Copy)] | ||
| 13 | pub(crate) enum Image { | ||
| 14 | Base, | ||
| 15 | Remote, | ||
| 16 | } | ||
| 17 | |||
| 18 | impl Image { | ||
| 19 | fn table(self) -> &'static str { | ||
| 20 | match self { | ||
| 21 | Self::Base => "base", | ||
| 22 | Self::Remote => "remote", | ||
| 23 | } | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | fn 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. | ||
| 32 | pub(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. | ||
| 53 | pub(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. | ||
| 78 | pub(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 | |||
| 101 | pub(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. | ||
| 108 | pub(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)] | ||
| 155 | mod 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 | |||
| 5 | use crate::Result; | ||
| 6 | use onestore::Stamp; | ||
| 7 | use rusqlite::{Connection, params}; | ||
| 8 | use std::io; | ||
| 9 | |||
| 10 | const 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. | ||
| 14 | fn 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 | |||
| 41 | fn 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 | |||
| 75 | pub(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)`. | ||
| 80 | pub(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. | ||
| 90 | pub(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. | ||
| 107 | pub(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. | ||
| 116 | pub(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 | |||
| 130 | pub(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. | ||
| 139 | pub(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. | ||
| 149 | pub(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)] | ||
| 166 | mod tests { | ||
| 167 | use super::*; | ||
| 168 | |||
| 169 | #[test] | ||
| 170 | fn a_working_image_survives_as_its_difference_from_any_base() { | ||
| 171 | let base: Vec<u8> = (0..3 * BLOCK + 17).map(|i| i as u8).collect(); | ||
| 172 | let mut edited = base.clone(); | ||
| 173 | edited[5] ^= 1; | ||
| 174 | edited[2 * BLOCK + 1] ^= 1; | ||
| 175 | edited.extend_from_slice(b"appended revision"); | ||
| 176 | for image in [ | ||
| 177 | base.clone(), | ||
| 178 | edited.clone(), | ||
| 179 | base[..BLOCK + 3].to_vec(), | ||
| 180 | Vec::new(), | ||
| 181 | vec![7; 5 * BLOCK], | ||
| 182 | ] { | ||
| 183 | let patch = difference(&base, &image); | ||
| 184 | assert_eq!(restore(base.clone(), &patch).unwrap(), image); | ||
| 185 | } | ||
| 186 | assert!(difference(&base, &base).is_empty()); | ||
| 187 | assert!(difference(&base, &edited).len() < 3 * BLOCK); | ||
| 188 | let mut huge = difference(&base, &edited); | ||
| 189 | huge[..8].copy_from_slice(&u64::MAX.to_le_bytes()); | ||
| 190 | assert!(restore(base.clone(), &huge).is_err()); | ||
| 191 | let patch = difference(&base, &edited); | ||
| 192 | for cut in 1..patch.len() { | ||
| 193 | if let Ok(image) = restore(base.clone(), &patch[..cut]) { | ||
| 194 | assert_ne!(image, edited); | ||
| 195 | } | ||
| 196 | } | ||
| 197 | } | ||
| 198 | } | ||
crates/notebook/src/lib.rs+221-415| ... | @@ -6,32 +6,33 @@ pub mod discover; | ... | @@ -6,32 +6,33 @@ pub mod discover; |
| 6 | pub mod smb; | 6 | pub mod smb; |
| 7 | 7 | ||
| 8 | use onestore::{ | 8 | use onestore::{ |
| 9 | ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store, | 9 | ExGuid, |
| 10 | document::{Document, Kind}, | 10 | op::{Edit, OpError}, |
| 11 | page::Page, | 11 | page::Page, |
| 12 | }; | 12 | }; |
| 13 | use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior, params}; | 13 | use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; |
| 14 | use std::{ | 14 | use std::{ |
| 15 | fs::OpenOptions, | 15 | fs::OpenOptions, |
| 16 | io, | 16 | io, |
| 17 | path::Path, | 17 | path::Path, |
| 18 | sync::{Mutex, atomic::AtomicI64}, | 18 | sync::{Arc, Mutex, MutexGuard, mpsc}, |
| 19 | thread::JoinHandle, | ||
| 19 | time::Duration, | 20 | time::Duration, |
| 20 | }; | 21 | }; |
| 21 | 22 | ||
| 22 | mod assets; | 23 | mod assets; |
| 23 | mod images; | 24 | mod base; |
| 24 | mod merge; | 25 | mod merge; |
| 25 | mod pages; | 26 | mod migrate; |
| 26 | mod rebase; | 27 | mod queue; |
| 27 | mod recovery; | 28 | mod recovery; |
| 28 | mod schema; | 29 | mod schema; |
| 29 | pub mod session; | 30 | pub mod session; |
| 30 | pub use pages::PageEdits; | ||
| 31 | pub use recovery::{Recovery, RecoverySummary}; | 31 | pub use recovery::{Recovery, RecoverySummary}; |
| 32 | mod sync; | 32 | mod sync; |
| 33 | pub use sync::{ConflictKind, EditStatus, Remote}; | 33 | pub use sync::{ConflictKind, EditStatus, Remote, Synced}; |
| 34 | mod worker; | 34 | mod worker; |
| 35 | mod working; | ||
| 35 | #[cfg(feature = "smb")] | 36 | #[cfg(feature = "smb")] |
| 36 | pub use smb::SmbRemote; | 37 | pub use smb::SmbRemote; |
| 37 | pub use worker::SyncWorker; | 38 | pub use worker::SyncWorker; |
| ... | @@ -50,6 +51,9 @@ pub enum Error { | ... | @@ -50,6 +51,9 @@ pub enum Error { |
| 50 | RemoteIo(io::Error), | 51 | RemoteIo(io::Error), |
| 51 | #[error(transparent)] | 52 | #[error(transparent)] |
| 52 | Discovery(#[from] discover::Error), | 53 | Discovery(#[from] discover::Error), |
| 54 | /// The section refused an edit; the pages it names are as they were. | ||
| 55 | #[error(transparent)] | ||
| 56 | Rejected(#[from] OpError), | ||
| 53 | #[error("External payload identity now refers to different bytes")] | 57 | #[error("External payload identity now refers to different bytes")] |
| 54 | AssetChanged, | 58 | AssetChanged, |
| 55 | #[cfg(feature = "protected")] | 59 | #[cfg(feature = "protected")] |
| ... | @@ -60,114 +64,49 @@ pub enum Error { | ... | @@ -60,114 +64,49 @@ pub enum Error { |
| 60 | type Result<T> = std::result::Result<T, Error>; | 64 | type Result<T> = std::result::Result<T, Error>; |
| 61 | 65 | ||
| 62 | const APPLICATION_ID: u32 = 0x4f4e454f; | 66 | const APPLICATION_ID: u32 = 0x4f4e454f; |
| 63 | const 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)] | ||
| 69 | pub struct PageIntent { | ||
| 70 | pub before: Page, | ||
| 71 | pub after: Page, | ||
| 72 | pub author: String, | ||
| 73 | } | ||
| 74 | |||
| 75 | impl 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)] | ||
| 142 | pub 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 | } | ||
| 150 | 67 | ||
| 151 | /// A locally acknowledged intent; its ID remains stable across cache reopen. | 68 | /// A locally durable edit; its ID remains stable across cache reopen. |
| 152 | #[derive(Debug, Clone, PartialEq)] | 69 | #[derive(Debug, Clone, PartialEq)] |
| 153 | pub struct PendingEdit { | 70 | pub 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)] | ||
| 79 | pub struct Conflict { | ||
| 154 | pub id: u64, | 80 | pub id: u64, |
| 155 | pub space: ExGuid, | 81 | pub space: ExGuid, |
| 156 | pub operation: Operation, | 82 | pub kind: ConflictKind, |
| 83 | } | ||
| 84 | |||
| 85 | /// How a conflict or an uncertain attempt ends. `Mine` keeps the local pages: a conflicted | ||
| 86 | /// page is rewritten from the remote page to the local one; a released attempt publishes | ||
| 87 | /// again. `Theirs` drops the local edits to the conflicted page, or the whole unpublished | ||
| 88 | /// branch of a released attempt. | ||
| 89 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 90 | pub enum Resolution { | ||
| 91 | Mine, | ||
| 92 | Theirs, | ||
| 157 | } | 93 | } |
| 158 | 94 | ||
| 159 | /// Owns one local cache. Share this handle between threads; a second open fails busy. | 95 | /// Owns one local cache. Share this handle between threads; a second open fails busy. |
| 160 | /// SQLite's exclusive connection retains ownership between local transactions. | 96 | /// Edits apply on the cache's section thread, which keeps the section parsed; SQLite's |
| 97 | /// exclusive connection retains ownership between local transactions. | ||
| 161 | pub struct Replica { | 98 | pub struct Replica { |
| 162 | connection: Mutex<Connection>, | 99 | connection: Arc<Mutex<Connection>>, |
| 100 | working: Option<mpsc::Sender<working::Request>>, | ||
| 101 | thread: Option<JoinHandle<()>>, | ||
| 163 | synchronization: Mutex<()>, | 102 | synchronization: Mutex<()>, |
| 164 | /// The intent a synchronization step selected for publication, or zero. | 103 | worker: Arc<Mutex<std::sync::Weak<worker::Signal>>>, |
| 165 | in_flight: AtomicI64, | 104 | /// The section's root object space, which names the document. |
| 166 | worker: Mutex<std::sync::Weak<worker::Signal>>, | 105 | root: ExGuid, |
| 167 | } | 106 | } |
| 168 | 107 | ||
| 169 | impl Replica { | 108 | impl Replica { |
| 170 | /// Seeds a new cache from a validated notebook image, refusing any existing path. | 109 | /// Seeds a new cache from a validated section image, refusing any existing path. |
| 171 | /// An initialization error preserves the created file for inspection. | 110 | /// An initialization error preserves the created file for inspection. |
| 172 | pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> { | 111 | pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> { |
| 173 | validate(source)?; | 112 | validate(source)?; |
| ... | @@ -179,364 +118,224 @@ impl Replica { | ... | @@ -179,364 +118,224 @@ impl Replica { |
| 179 | options.mode(0o600); | 118 | options.mode(0o600); |
| 180 | } | 119 | } |
| 181 | drop(options.open(path.as_ref())?); | 120 | drop(options.open(path.as_ref())?); |
| 182 | Self::connect(path.as_ref(), Some(source)) | 121 | let mut connection = cache_connection(path.as_ref())?; |
| 122 | write_ahead(&connection)?; | ||
| 123 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?; | ||
| 124 | let tables: i64 = | ||
| 125 | transaction.query_row("SELECT count(*) FROM sqlite_schema", [], |row| row.get(0))?; | ||
| 126 | let application: u32 = | ||
| 127 | transaction.pragma_query_value(None, "application_id", |row| row.get(0))?; | ||
| 128 | if application != 0 || tables != 0 { | ||
| 129 | return Err(io::Error::new( | ||
| 130 | io::ErrorKind::AlreadyExists, | ||
| 131 | "Cache initialization found an existing database", | ||
| 132 | ) | ||
| 133 | .into()); | ||
| 134 | } | ||
| 135 | transaction.pragma_update(None, "application_id", APPLICATION_ID)?; | ||
| 136 | transaction.pragma_update(None, "user_version", schema::VERSION)?; | ||
| 137 | schema::create(&transaction)?; | ||
| 138 | base::write(&transaction, base::Image::Base, source)?; | ||
| 139 | transaction.commit()?; | ||
| 140 | Self::start(connection) | ||
| 183 | } | 141 | } |
| 184 | 142 | ||
| 185 | /// Reopens an existing cache and its durable pending edits without network access. | 143 | /// Reopens an existing cache and its durable pending edits without network access. |
| 186 | /// Unrecognized databases and unsupported journal modes are rejected without conversion. | 144 | /// A schema-14 cache is converted once, after exporting it to `<path>.v14-recovery`; |
| 145 | /// a conversion that cannot reproduce every queued page leaves it untouched. | ||
| 146 | /// Unrecognized databases and unsupported journal modes are rejected. | ||
| 187 | pub fn open(path: impl AsRef<Path>) -> Result<Self> { | 147 | pub fn open(path: impl AsRef<Path>) -> Result<Self> { |
| 188 | Self::connect(path.as_ref(), None) | 148 | let path = path.as_ref(); |
| 189 | } | ||
| 190 | |||
| 191 | fn connect(path: &Path, source: Option<&[u8]>) -> Result<Self> { | ||
| 192 | let mut connection = cache_connection(path)?; | 149 | let mut connection = cache_connection(path)?; |
| 193 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?; | ||
| 194 | let application: u32 = | 150 | let application: u32 = |
| 195 | transaction.pragma_query_value(None, "application_id", |row| row.get(0))?; | 151 | connection.pragma_query_value(None, "application_id", |row| row.get(0))?; |
| 196 | let version: u32 = | 152 | if application != APPLICATION_ID { |
| 197 | transaction.pragma_query_value(None, "user_version", |row| row.get(0))?; | 153 | return Err(io::Error::new(io::ErrorKind::InvalidData, "Not a notebook cache").into()); |
| 198 | if let Some(source) = source { | 154 | } |
| 199 | let tables: i64 = | 155 | let integrity: String = connection.query_row("PRAGMA quick_check", [], |row| row.get(0))?; |
| 200 | transaction | 156 | if integrity != "ok" { |
| 201 | .query_row("SELECT count(*) FROM sqlite_schema", [], |row| row.get(0))?; | 157 | return Err( |
| 202 | if application != 0 || version != 0 || tables != 0 { | 158 | io::Error::new(io::ErrorKind::InvalidData, "Cache integrity check failed").into(), |
| 203 | return Err(io::Error::new( | 159 | ); |
| 204 | io::ErrorKind::AlreadyExists, | 160 | } |
| 205 | "Cache initialization found an existing database", | 161 | let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; |
| 206 | ) | 162 | // A schema-14 cache converts in its rollback journal, so a failed conversion leaves |
| 207 | .into()); | 163 | // the file as it was. |
| 208 | } | 164 | match version { |
| 209 | transaction.pragma_update(None, "application_id", APPLICATION_ID)?; | 165 | migrate::VERSION => migrate::migrate(&mut connection, path)?, |
| 210 | transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?; | 166 | schema::VERSION => {} |
| 211 | transaction.execute_batch( | 167 | _ => { |
| 212 | " | ||
| 213 | CREATE TABLE replica ( | ||
| 214 | id INTEGER PRIMARY KEY CHECK(id=1), | ||
| 215 | base BLOB NOT NULL, | ||
| 216 | working BLOB NOT NULL | ||
| 217 | ) STRICT; | ||
| 218 | ", | ||
| 219 | )?; | ||
| 220 | schema::create(&transaction)?; | ||
| 221 | transaction.execute("INSERT INTO replica VALUES (1, ?1, x'')", [source])?; | ||
| 222 | } else { | ||
| 223 | if application != APPLICATION_ID { | ||
| 224 | return Err( | ||
| 225 | io::Error::new(io::ErrorKind::InvalidData, "Not a notebook cache").into(), | ||
| 226 | ); | ||
| 227 | } | ||
| 228 | if version != SCHEMA_VERSION { | ||
| 229 | // Older caches are refused rather than migrated until the application is | ||
| 230 | // usable end to end; the queue must be drained by the build that wrote it. | ||
| 231 | return Err(io::Error::new( | 168 | return Err(io::Error::new( |
| 232 | io::ErrorKind::InvalidData, | 169 | io::ErrorKind::InvalidData, |
| 233 | format!("Cache schema version {version} is not the supported version {SCHEMA_VERSION}"), | 170 | format!( |
| 171 | "Cache schema version {version} is not the supported version {}", | ||
| 172 | schema::VERSION | ||
| 173 | ), | ||
| 234 | ) | 174 | ) |
| 235 | .into()); | 175 | .into()); |
| 236 | } | 176 | } |
| 237 | validate_images(&transaction)?; | ||
| 238 | pending(&transaction)?; | ||
| 239 | } | 177 | } |
| 240 | transaction.commit()?; | 178 | write_ahead(&connection)?; |
| 179 | Self::start(connection) | ||
| 180 | } | ||
| 181 | |||
| 182 | fn start(connection: Connection) -> Result<Self> { | ||
| 183 | let connection = Arc::new(Mutex::new(connection)); | ||
| 184 | let worker = Arc::new(Mutex::new(std::sync::Weak::new())); | ||
| 185 | let (sender, thread, root) = working::spawn(Arc::clone(&connection), Arc::clone(&worker))?; | ||
| 241 | Ok(Self { | 186 | Ok(Self { |
| 242 | connection: Mutex::new(connection), | 187 | connection, |
| 188 | working: Some(sender), | ||
| 189 | thread: Some(thread), | ||
| 243 | synchronization: Mutex::new(()), | 190 | synchronization: Mutex::new(()), |
| 244 | in_flight: AtomicI64::new(0), | 191 | worker, |
| 245 | worker: Mutex::new(std::sync::Weak::new()), | 192 | root, |
| 246 | }) | 193 | }) |
| 247 | } | 194 | } |
| 248 | 195 | ||
| 249 | /// Returns the latest complete locally committed image, including pending edits. | 196 | fn lock(&self) -> Result<MutexGuard<'_, Connection>> { |
| 250 | pub fn snapshot(&self) -> Result<Vec<u8>> { | 197 | lock(&self.connection) |
| 251 | let connection = self | ||
| 252 | .connection | ||
| 253 | .lock() | ||
| 254 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 255 | images::working(&connection) | ||
| 256 | } | 198 | } |
| 257 | 199 | ||
| 258 | /// The page in `space` as the working image stores it. | 200 | /// Hands `request` to the section thread. |
| 259 | pub fn page(&self, space: ExGuid) -> Result<Page> { | 201 | fn send(&self, request: working::Request) -> Result<()> { |
| 260 | let snapshot = self.snapshot()?; | 202 | self.working |
| 261 | let store = Store::parse(&snapshot)?; | 203 | .as_ref() |
| 262 | let index = RevisionIndex::parse(&store)?; | 204 | .and_then(|working| working.send(request).ok()) |
| 263 | Ok(Page::from_space(&Document::parse(&index)?, space)?) | 205 | .ok_or_else(|| io::Error::other("The section thread stopped").into()) |
| 264 | } | 206 | } |
| 265 | 207 | ||
| 266 | /// Whether no edit is queued and the remote still has the last observed image's stamp, | 208 | /// Asks the section thread and waits for its answer. |
| 267 | /// reading neither image; the cache is not locked during remote I/O. | 209 | fn ask<T: Send + 'static>( |
| 268 | pub(crate) fn settled(&self, remote: &mut impl Remote) -> Result<bool> { | 210 | &self, |
| 269 | let lock = || { | 211 | request: impl FnOnce(working::Reply<T>) -> working::Request, |
| 270 | self.connection | 212 | ) -> Result<T> { |
| 271 | .lock() | 213 | let (sender, receiver) = mpsc::sync_channel(1); |
| 272 | .map_err(|_| io::Error::other("Cache owner panicked")) | 214 | self.send(request(Box::new(move |result| { |
| 273 | }; | 215 | let _ = sender.send(result); |
| 274 | if !images::settled(&*lock()?)? { | 216 | })))?; |
| 275 | return Ok(false); | 217 | receiver |
| 276 | } | 218 | .recv() |
| 277 | let Some(stamp) = remote.stamp().map_err(Error::RemoteIo)? else { | 219 | .map_err(|_| io::Error::other("The section thread stopped"))? |
| 278 | return Ok(false); | ||
| 279 | }; | ||
| 280 | Ok(images::stamp(&*lock()?)? == stamp) | ||
| 281 | } | ||
| 282 | |||
| 283 | pub fn pending(&self) -> Result<Vec<PendingEdit>> { | ||
| 284 | let connection = self | ||
| 285 | .connection | ||
| 286 | .lock() | ||
| 287 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 288 | pending(&connection) | ||
| 289 | } | 220 | } |
| 290 | 221 | ||
| 291 | /// Identities and page spaces of the pending edits, oldest first, without reading their | 222 | /// Applies an edit and queues it durably for publication, returning its id once |
| 292 | /// operations. | 223 | /// written. A refused edit returns `Rejected` and leaves every page as it was. |
| 293 | pub(crate) fn queued(&self) -> Result<Vec<(u64, ExGuid)>> { | 224 | pub fn apply(&self, author: &str, edit: Edit) -> Result<u64> { |
| 294 | let connection = self | 225 | self.ask(|reply| working::Request::Apply { |
| 295 | .connection | 226 | author: author.to_owned(), |
| 296 | .lock() | 227 | edit, |
| 297 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | 228 | reply, |
| 298 | let mut query = connection.prepare("SELECT id, space FROM edits ORDER BY id")?; | 229 | }) |
| 299 | let mut rows = query.query([])?; | ||
| 300 | let mut edits = Vec::new(); | ||
| 301 | while let Some(row) = rows.next()? { | ||
| 302 | edits.push(( | ||
| 303 | u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?, | ||
| 304 | row.get::<_, String>(1)?.parse()?, | ||
| 305 | )); | ||
| 306 | } | ||
| 307 | Ok(edits) | ||
| 308 | } | 230 | } |
| 309 | 231 | ||
| 310 | /// Durably queues an edited page model using the supplied local snapshot. | 232 | /// `apply` without waiting: `reply` runs on the section thread once the edit is |
| 311 | /// While the newest pending edit is an unattempted save of the same page, a new save | 233 | /// durable or refused. |
| 312 | /// replaces its result instead of queueing another publication, as OneNote does | 234 | pub(crate) fn submit( |
| 313 | /// within its own save interval. An unchanged model returns `None`. | ||
| 314 | pub fn save( | ||
| 315 | &self, | 235 | &self, |
| 316 | source: &[u8], | ||
| 317 | space: ExGuid, | ||
| 318 | after: &Page, | ||
| 319 | author: &str, | 236 | author: &str, |
| 320 | ) -> Result<Option<u64>> { | 237 | edit: Edit, |
| 321 | Ok(self.save_image(source, space, after, author)?.0) | 238 | reply: working::Reply<u64>, |
| 239 | ) -> Result<()> { | ||
| 240 | self.send(working::Request::Apply { | ||
| 241 | author: author.to_owned(), | ||
| 242 | edit, | ||
| 243 | reply, | ||
| 244 | }) | ||
| 322 | } | 245 | } |
| 323 | 246 | ||
| 324 | /// `save`, with the working image it leaves. | 247 | /// The page in `space` as the queued edits leave it; O(page), for opening and reloading. |
| 325 | pub(crate) fn save_image( | 248 | pub fn page(&self, space: ExGuid) -> Result<Page> { |
| 326 | &self, | 249 | self.ask(|reply| working::Request::Page { space, reply }) |
| 327 | source: &[u8], | ||
| 328 | space: ExGuid, | ||
| 329 | after: &Page, | ||
| 330 | author: &str, | ||
| 331 | ) -> Result<(Option<u64>, Vec<u8>)> { | ||
| 332 | let prepared = PreparedEdit::page(source, space, after, author)?; | ||
| 333 | if prepared.as_bytes() == source { | ||
| 334 | return Ok((None, source.to_vec())); | ||
| 335 | } | ||
| 336 | let before = page_of(source, space)?.ok_or_else(|| { | ||
| 337 | io::Error::new( | ||
| 338 | io::ErrorKind::InvalidData, | ||
| 339 | "The saved page is not in the local image", | ||
| 340 | ) | ||
| 341 | })?; | ||
| 342 | { | ||
| 343 | let mut connection = self | ||
| 344 | .connection | ||
| 345 | .lock() | ||
| 346 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 347 | let transaction = | ||
| 348 | connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 349 | let (base, current) = images::both(&transaction)?; | ||
| 350 | if current != source { | ||
| 351 | return Err(io::Error::new( | ||
| 352 | io::ErrorKind::ResourceBusy, | ||
| 353 | "The local snapshot changed before this edit", | ||
| 354 | ) | ||
| 355 | .into()); | ||
| 356 | } | ||
| 357 | let newest: Option<(i64, String, String)> = transaction | ||
| 358 | .query_row( | ||
| 359 | "SELECT id, space, operation FROM edits WHERE id=(SELECT max(id) FROM edits)", | ||
| 360 | [], | ||
| 361 | |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), | ||
| 362 | ) | ||
| 363 | .optional()?; | ||
| 364 | // The intent a step is publishing has no attempt row yet; rewriting it would lose | ||
| 365 | // this save under the published bytes. | ||
| 366 | if let Some((id, sid, operation)) = newest | ||
| 367 | && id != self.in_flight.load(std::sync::atomic::Ordering::Acquire) | ||
| 368 | && sid == space.to_string() | ||
| 369 | && let Ok(Operation::Page(mut head)) = serde_json::from_str::<Operation>(&operation) | ||
| 370 | { | ||
| 371 | let attempted: bool = transaction.query_row( | ||
| 372 | "SELECT EXISTS(SELECT 1 FROM attempt WHERE edit_id=?1) OR EXISTS(SELECT 1 FROM conflicts WHERE edit_id=?1)", | ||
| 373 | [id], | ||
| 374 | |row| row.get(0), | ||
| 375 | )?; | ||
| 376 | if !attempted { | ||
| 377 | head.after = after.clone(); | ||
| 378 | transaction.execute( | ||
| 379 | "UPDATE edits SET operation=?1 WHERE id=?2", | ||
| 380 | params![ | ||
| 381 | serde_json::to_string(&Operation::Page(head)) | ||
| 382 | .map_err(io::Error::other)?, | ||
| 383 | id | ||
| 384 | ], | ||
| 385 | )?; | ||
| 386 | images::set_working(&transaction, &base, prepared.as_bytes())?; | ||
| 387 | transaction.commit()?; | ||
| 388 | drop(connection); | ||
| 389 | self.wake_sync(); | ||
| 390 | let id = u64::try_from(id).map_err(io::Error::other)?; | ||
| 391 | return Ok((Some(id), prepared.as_bytes().to_vec())); | ||
| 392 | } | ||
| 393 | } | ||
| 394 | } | ||
| 395 | let id = self.record( | ||
| 396 | source, | ||
| 397 | space, | ||
| 398 | Operation::Page(PageIntent { | ||
| 399 | before, | ||
| 400 | after: after.clone(), | ||
| 401 | author: author.to_owned(), | ||
| 402 | }), | ||
| 403 | &prepared, | ||
| 404 | )?; | ||
| 405 | Ok((id, prepared.as_bytes().to_vec())) | ||
| 406 | } | 250 | } |
| 407 | 251 | ||
| 408 | /// Queues a new page and its section entry with stable identities for dependent edits. | 252 | /// Page spaces, titles and outline levels (1 at the top) in section order. |
| 409 | pub fn create_page(&self, source: &[u8], page: &PageCreation) -> Result<Option<u64>> { | 253 | pub fn pages(&self) -> Result<Vec<(ExGuid, String, u32)>> { |
| 410 | let edit = PreparedEdit::create_page(source, page)?; | 254 | self.ask(|reply| working::Request::Pages { reply }) |
| 411 | self.record( | ||
| 412 | source, | ||
| 413 | page.space(), | ||
| 414 | Operation::CreatePage(page.clone()), | ||
| 415 | &edit, | ||
| 416 | ) | ||
| 417 | } | 255 | } |
| 418 | 256 | ||
| 419 | /// Queues the permanent removal of explicitly selected pages; the batch is republished | 257 | /// The section image the queued edits leave, the unsealed ones sealed as one more |
| 420 | /// only while every selected page still exists remotely. | 258 | /// revision whose identities differ per call: O(section), for tests and diagnostics. |
| 421 | pub fn delete_pages(&self, source: &[u8], pages: &[ExGuid]) -> Result<Option<u64>> { | 259 | pub fn snapshot(&self) -> Result<Vec<u8>> { |
| 422 | let edit = PreparedEdit::delete_pages_permanently(source, pages)?; | 260 | self.ask(|reply| working::Request::Image { reply }) |
| 423 | let space = validate(source)?; | ||
| 424 | self.record(source, space, Operation::DeletePages(pages.to_vec()), &edit) | ||
| 425 | } | 261 | } |
| 426 | 262 | ||
| 427 | fn record( | 263 | /// The section file's identity, which internal links name as `section-id`. |
| 428 | &self, | 264 | pub fn identity(&self) -> Result<[u8; 16]> { |
| 429 | source: &[u8], | 265 | let stamp = base::stamp(&*self.lock()?, base::Image::Base)? |
| 430 | space: ExGuid, | 266 | .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Damaged cached image"))?; |
| 431 | operation: Operation, | 267 | Ok(onestore::Header::parse(&stamp.header)?.file_id) |
| 432 | edit: &PreparedEdit<'_>, | 268 | } |
| 433 | ) -> Result<Option<u64>> { | 269 | |
| 434 | let mut connection = self | 270 | /// Queued edits, oldest first. |
| 435 | .connection | 271 | pub fn pending(&self) -> Result<Vec<PendingEdit>> { |
| 436 | .lock() | 272 | pending(&*self.lock()?) |
| 437 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 438 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 439 | let (base, current) = images::both(&transaction)?; | ||
| 440 | if current != source { | ||
| 441 | return Err(io::Error::new( | ||
| 442 | io::ErrorKind::ResourceBusy, | ||
| 443 | "The local snapshot changed before this edit", | ||
| 444 | ) | ||
| 445 | .into()); | ||
| 446 | } | ||
| 447 | if edit.as_bytes() == source { | ||
| 448 | return Ok(None); | ||
| 449 | } | ||
| 450 | transaction.execute( | ||
| 451 | "INSERT INTO edits(space, operation) VALUES (?1, ?2)", | ||
| 452 | params![ | ||
| 453 | space.to_string(), | ||
| 454 | serde_json::to_string(&operation).map_err(io::Error::other)? | ||
| 455 | ], | ||
| 456 | )?; | ||
| 457 | let id = u64::try_from(transaction.last_insert_rowid()).map_err(io::Error::other)?; | ||
| 458 | images::set_working(&transaction, &base, edit.as_bytes())?; | ||
| 459 | transaction.commit()?; | ||
| 460 | drop(connection); | ||
| 461 | self.wake_sync(); | ||
| 462 | Ok(Some(id)) | ||
| 463 | } | 273 | } |
| 464 | 274 | ||
| 465 | fn wake_sync(&self) { | 275 | fn wake_sync(&self) { |
| 466 | if let Ok(worker) = self.worker.lock() | 276 | wake(&self.worker); |
| 467 | && let Some(worker) = worker.upgrade() | 277 | } |
| 468 | { | 278 | } |
| 469 | worker.wake(); | 279 | |
| 280 | impl Drop for Replica { | ||
| 281 | fn drop(&mut self) { | ||
| 282 | drop(self.working.take()); | ||
| 283 | if let Some(thread) = self.thread.take() { | ||
| 284 | let _ = thread.join(); | ||
| 470 | } | 285 | } |
| 471 | } | 286 | } |
| 472 | } | 287 | } |
| 473 | 288 | ||
| 474 | fn page_of(source: &[u8], space: ExGuid) -> Result<Option<Page>> { | 289 | fn lock(connection: &Mutex<Connection>) -> Result<MutexGuard<'_, Connection>> { |
| 475 | let store = Store::parse(source)?; | 290 | connection |
| 476 | let index = RevisionIndex::parse(&store)?; | 291 | .lock() |
| 477 | let document = Document::parse(&index)?; | 292 | .map_err(|_| io::Error::other("Cache owner panicked").into()) |
| 478 | Ok(Page::from_space(&document, space).ok()) | ||
| 479 | } | 293 | } |
| 480 | 294 | ||
| 481 | fn validate(source: &[u8]) -> Result<ExGuid> { | 295 | fn wake(worker: &Mutex<std::sync::Weak<worker::Signal>>) { |
| 482 | let store = Store::parse(source)?; | 296 | if let Ok(worker) = worker.lock() |
| 483 | if !store.checksum_mismatches.is_empty() { | 297 | && let Some(worker) = worker.upgrade() |
| 484 | return Err(io::Error::new( | 298 | { |
| 485 | io::ErrorKind::InvalidData, | 299 | worker.wake(); |
| 486 | "Notebook transaction checksum damage", | ||
| 487 | ) | ||
| 488 | .into()); | ||
| 489 | } | 300 | } |
| 490 | let index = RevisionIndex::parse(&store)?; | 301 | } |
| 491 | index.validate_current()?; | 302 | |
| 492 | Document::parse(&index)?; | 303 | /// Fully validates a section image, returning its root object space. |
| 493 | Ok(index.root) | 304 | fn validate(source: &[u8]) -> Result<ExGuid> { |
| 305 | let arena = onestore::Arena::default(); | ||
| 306 | Ok(onestore::Section::open(&arena, source.to_vec())?.root()) | ||
| 494 | } | 307 | } |
| 495 | 308 | ||
| 496 | fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> { | 309 | fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> { |
| 497 | let mut query = connection.prepare("SELECT id, space, operation FROM edits ORDER BY id")?; | 310 | queue::load(connection, None) |
| 498 | let mut rows = query.query([])?; | ||
| 499 | let mut edits = Vec::new(); | ||
| 500 | while let Some(row) = rows.next()? { | ||
| 501 | edits.push(pending_edit(row)?); | ||
| 502 | } | ||
| 503 | Ok(edits) | ||
| 504 | } | 311 | } |
| 505 | 312 | ||
| 506 | fn pending_edit(row: &rusqlite::Row<'_>) -> Result<PendingEdit> { | 313 | fn unsigned(value: i64) -> Result<u64> { |
| 507 | let edit = PendingEdit { | 314 | Ok(u64::try_from(value).map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?) |
| 508 | id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?, | 315 | } |
| 509 | space: row.get::<_, String>(1)?.parse()?, | 316 | |
| 510 | operation: serde_json::from_str(&row.get::<_, String>(2)?) | 317 | fn signed(value: u64) -> Result<i64> { |
| 511 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?, | 318 | Ok(i64::try_from(value).map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?) |
| 512 | }; | ||
| 513 | if matches!(&edit.operation, Operation::CreatePage(page) if page.space() != edit.space) { | ||
| 514 | return Err(io::Error::new( | ||
| 515 | io::ErrorKind::InvalidData, | ||
| 516 | "Cached page identity differs from its creation intent", | ||
| 517 | ) | ||
| 518 | .into()); | ||
| 519 | } | ||
| 520 | Ok(edit) | ||
| 521 | } | 319 | } |
| 522 | 320 | ||
| 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. | ||
| 523 | fn cache_connection(path: &Path) -> Result<Connection> { | 323 | fn cache_connection(path: &Path) -> Result<Connection> { |
| 524 | let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; | 324 | let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; |
| 525 | connection.busy_timeout(Duration::ZERO)?; | 325 | connection.busy_timeout(Duration::ZERO)?; |
| 526 | connection.execute_batch( | 326 | connection.execute_batch( |
| 527 | "PRAGMA locking_mode=EXCLUSIVE; PRAGMA synchronous=EXTRA; PRAGMA fullfsync=ON; PRAGMA foreign_keys=ON;", | 327 | "PRAGMA locking_mode=EXCLUSIVE; PRAGMA synchronous=FULL; PRAGMA fullfsync=ON; PRAGMA foreign_keys=ON;", |
| 528 | )?; | 328 | )?; |
| 529 | for (name, expected) in [("locking_mode", "exclusive"), ("journal_mode", "delete")] { | 329 | let locking: String = connection.pragma_query_value(None, "locking_mode", |row| row.get(0))?; |
| 530 | let actual: String = connection.pragma_query_value(None, name, |row| row.get(0))?; | 330 | let journal: String = connection.pragma_query_value(None, "journal_mode", |row| row.get(0))?; |
| 531 | if actual != expected { | 331 | if locking != "exclusive" || !["wal", "delete"].contains(&journal.as_str()) { |
| 532 | return Err(io::Error::new( | 332 | return Err(io::Error::new( |
| 533 | io::ErrorKind::InvalidData, | 333 | io::ErrorKind::InvalidData, |
| 534 | "Unsupported cache locking or journal mode", | 334 | "Unsupported cache locking or journal mode", |
| 535 | ) | 335 | ) |
| 536 | .into()); | 336 | .into()); |
| 537 | } | ||
| 538 | } | 337 | } |
| 539 | for (name, expected) in [("synchronous", 3), ("fullfsync", 1), ("foreign_keys", 1)] { | 338 | for (name, expected) in [("synchronous", 2), ("fullfsync", 1), ("foreign_keys", 1)] { |
| 540 | let actual: i64 = connection.pragma_query_value(None, name, |row| row.get(0))?; | 339 | let actual: i64 = connection.pragma_query_value(None, name, |row| row.get(0))?; |
| 541 | if actual != expected { | 340 | if actual != expected { |
| 542 | return Err(io::Error::new( | 341 | return Err(io::Error::new( |
| ... | @@ -549,20 +348,27 @@ fn cache_connection(path: &Path) -> Result<Connection> { | ... | @@ -549,20 +348,27 @@ fn cache_connection(path: &Path) -> Result<Connection> { |
| 549 | Ok(connection) | 348 | Ok(connection) |
| 550 | } | 349 | } |
| 551 | 350 | ||
| 552 | fn validate_images(connection: &Connection) -> Result<()> { | 351 | /// Switches a cache to write-ahead logging: a commit appends its pages to `<cache>-wal` |
| 553 | let integrity: String = connection.query_row("PRAGMA quick_check", [], |row| row.get(0))?; | 352 | /// instead of copying the originals to a rollback journal. Under exclusive locking the WAL |
| 554 | if integrity != "ok" { | 353 | /// index lives in memory, so the WAL is the only file beside the cache; it is checkpointed |
| 555 | return Err( | 354 | /// and removed on close, and replayed on the next open after a crash. |
| 556 | io::Error::new(io::ErrorKind::InvalidData, "Cache integrity check failed").into(), | 355 | fn write_ahead(connection: &Connection) -> Result<()> { |
| 557 | ); | 356 | let mode: String = |
| 558 | } | 357 | connection.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))?; |
| 559 | let (base, working) = images::both(connection)?; | 358 | if mode != "wal" { |
| 560 | if validate(&base)? != validate(&working)? { | ||
| 561 | return Err(io::Error::new( | 359 | return Err(io::Error::new( |
| 562 | io::ErrorKind::InvalidData, | 360 | io::ErrorKind::Unsupported, |
| 563 | "Cache images belong to different documents", | 361 | "The cache cannot use write-ahead logging", |
| 564 | ) | 362 | ) |
| 565 | .into()); | 363 | .into()); |
| 566 | } | 364 | } |
| 567 | Ok(()) | 365 | Ok(()) |
| 568 | } | 366 | } |
| 367 | |||
| 368 | /// FILETIME now, as an edit's `at`. | ||
| 369 | pub(crate) fn now() -> u64 { | ||
| 370 | let unix = std::time::SystemTime::now() | ||
| 371 | .duration_since(std::time::UNIX_EPOCH) | ||
| 372 | .unwrap_or_default(); | ||
| 373 | (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100) | ||
| 374 | } |
crates/notebook/src/merge.rs+1102-476| ... | @@ -1,554 +1,1180 @@ | ... | @@ -1,554 +1,1180 @@ |
| 1 | //! Three-way merge of page models: the remote page keeps everything it changed, and the | 1 | //! Rebasing queued edits onto a changed remote section. Each op replays on the remote |
| 2 | //! local changes (`base` → `ours`) are re-applied wherever the two sides touched | 2 | //! section when the objects it reads are as the base image had them; text ops on a text |
| 3 | //! different objects, fields or text ranges. Any overlap is a conflict for review. | 3 | //! the remote also changed shift past the remote's changes when they stay clear of them; |
| 4 | //! a move, deletion or outline setting the remote already made is dropped as done. | ||
| 5 | //! Anything else is a conflict: the queue stays on its base until it is resolved. | ||
| 6 | //! Objects the queue itself created are not in the base, so nothing remote can have | ||
| 7 | //! changed them. | ||
| 4 | 8 | ||
| 9 | use crate::{ConflictKind, PendingEdit, Result}; | ||
| 5 | use onestore::{ | 10 | use onestore::{ |
| 6 | ExGuid, | 11 | ExGuid, OutlineEdit, PageCreation, PageEdit, PagePosition, Section, |
| 7 | page::{Outline, Page, PageObject, PageParagraph, ParagraphContent, text::Edit}, | 12 | document::{Format, Layout, Tag}, |
| 13 | op::{Edit, Op, OpError, PageOp, SectionOp, TableEdit}, | ||
| 14 | page::{ | ||
| 15 | Attachment, Image, Ink, MediaIndex, Outline, Page, PageObject, PageParagraph, Paragraph, | ||
| 16 | ParagraphContent, TableColumn, TextObject, Unsupported, | ||
| 17 | }, | ||
| 18 | }; | ||
| 19 | use std::{ | ||
| 20 | collections::{BTreeMap, BTreeSet}, | ||
| 21 | ops::Range, | ||
| 8 | }; | 22 | }; |
| 9 | use std::collections::{BTreeMap, BTreeSet}; | ||
| 10 | 23 | ||
| 11 | pub(crate) fn merge(base: &Page, ours: &Page, theirs: &Page) -> Option<Page> { | 24 | /// A conflict being resolved: from edit `first` on, ops on `space` are dropped. Keeping |
| 12 | if ours == base { | 25 | /// mine replaces the first of them by the ops rewriting the remote page to `target` (or |
| 13 | return Some(theirs.clone()); | 26 | /// putting `target` back as a page where the remote removed it), or, for the section's |
| 14 | } | 27 | /// page list, keeps what of each section op still applies. |
| 15 | if theirs == base || ours == theirs { | 28 | pub(crate) struct Resolving<'p> { |
| 16 | return Some(ours.clone()); | 29 | pub first: u64, |
| 17 | } | 30 | pub space: ExGuid, |
| 18 | if ours.created != base.created || ours.margin_origin != base.margin_origin { | 31 | pub mine: bool, |
| 19 | return None; | 32 | pub target: Option<&'p Page>, |
| 20 | } | 33 | } |
| 21 | fn by_id(page: &Page) -> BTreeMap<ExGuid, &PageObject> { | 34 | |
| 22 | page.objects | 35 | pub(crate) enum Outcome { |
| 23 | .iter() | 36 | /// Every edit applied; these edits' ops changed. An edit whose ops were all dropped |
| 24 | .map(|object| (object.id(), object)) | 37 | /// stays, empty, to be published and acknowledged with its batch. |
| 25 | .collect() | 38 | Applied(Vec<(u64, Edit)>), |
| 26 | } | 39 | Conflict { |
| 27 | let (b, o, t) = (by_id(base), by_id(ours), by_id(theirs)); | 40 | id: u64, |
| 28 | let mut result: Vec<PageObject> = Vec::new(); | 41 | space: ExGuid, |
| 29 | for object in &theirs.objects { | 42 | kind: ConflictKind, |
| 30 | let id = object.id(); | 43 | }, |
| 31 | match (b.get(&id), o.get(&id)) { | 44 | } |
| 32 | (Some(before), Some(after)) => match (before, after, object) { | 45 | |
| 33 | (PageObject::Outline(x), PageObject::Outline(y), PageObject::Outline(z)) => { | 46 | /// Replays `edits` on `new`, the remote section; `old` is the base they were made on. |
| 34 | result.push(PageObject::Outline(merge_outline(x, y, z)?)); | 47 | pub(crate) fn rebase( |
| 48 | old: &mut Section<'_>, | ||
| 49 | new: &mut Section<'_>, | ||
| 50 | edits: &[PendingEdit], | ||
| 51 | converged: &BTreeSet<ExGuid>, | ||
| 52 | resolving: Option<Resolving<'_>>, | ||
| 53 | ) -> Result<Outcome> { | ||
| 54 | let root = new.root(); | ||
| 55 | let before: BTreeMap<ExGuid, ExGuid> = old.revisions().collect(); | ||
| 56 | let after: BTreeMap<ExGuid, ExGuid> = new.revisions().collect(); | ||
| 57 | let orders = (order(old)?, order(new)?); | ||
| 58 | let mut state = Replay { | ||
| 59 | before, | ||
| 60 | after, | ||
| 61 | orders, | ||
| 62 | created: BTreeSet::new(), | ||
| 63 | diffs: BTreeMap::new(), | ||
| 64 | }; | ||
| 65 | let mut rewritten = Vec::new(); | ||
| 66 | let mut resolved = false; | ||
| 67 | for queued in edits { | ||
| 68 | let branch = resolving | ||
| 69 | .as_ref() | ||
| 70 | .filter(|resolving| queued.id >= resolving.first); | ||
| 71 | let mut ops = Vec::new(); | ||
| 72 | let mut changed = false; | ||
| 73 | for op in &queued.edit.ops { | ||
| 74 | let conflict = |kind, space| Outcome::Conflict { | ||
| 75 | id: queued.id, | ||
| 76 | space, | ||
| 77 | kind, | ||
| 78 | }; | ||
| 79 | if let Some(resolving) = branch { | ||
| 80 | let dropped = match op { | ||
| 81 | Op::Page { space, .. } => *space == resolving.space, | ||
| 82 | Op::Section(_) => resolving.space == root, | ||
| 83 | }; | ||
| 84 | if dropped { | ||
| 85 | changed = true; | ||
| 86 | let rewrite = match op { | ||
| 87 | Op::Page { space, .. } if !resolved && resolving.mine => { | ||
| 88 | resolving.target.map(|target| (*space, target)) | ||
| 89 | } | ||
| 90 | _ => None, | ||
| 91 | }; | ||
| 92 | resolved |= matches!(op, Op::Page { .. }); | ||
| 93 | if let Some((space, target)) = rewrite { | ||
| 94 | if !state.listed(space) { | ||
| 95 | // Kept while the remote removed it: the page comes back. | ||
| 96 | match state.restore( | ||
| 97 | new, | ||
| 98 | space, | ||
| 99 | target, | ||
| 100 | &queued.author, | ||
| 101 | queued.edit.at, | ||
| 102 | )? { | ||
| 103 | Ok(restored) => ops.extend(restored), | ||
| 104 | Err(kind) => return Ok(conflict(kind, space)), | ||
| 105 | } | ||
| 106 | continue; | ||
| 107 | } | ||
| 108 | let current = new.page(space)?; | ||
| 109 | for op in onestore::op::lower_page(&current, target) | ||
| 110 | .map_err(|error| OpError::Unsupported(error.message))? | ||
| 111 | { | ||
| 112 | let op = Op::Page { space, op }; | ||
| 113 | if let Some(kind) = | ||
| 114 | state.apply(new, &queued.author, queued.edit.at, &op)? | ||
| 115 | { | ||
| 116 | return Ok(conflict(kind, space)); | ||
| 117 | } | ||
| 118 | ops.push(op); | ||
| 119 | } | ||
| 120 | } | ||
| 121 | if let (Op::Section(op), true) = (op, resolving.mine) { | ||
| 122 | // Keeping mine for the section list: the first form that applies. | ||
| 123 | for op in state.salvage(new, op) { | ||
| 124 | let op = Op::Section(op); | ||
| 125 | if state | ||
| 126 | .apply(new, &queued.author, queued.edit.at, &op)? | ||
| 127 | .is_none() | ||
| 128 | { | ||
| 129 | ops.push(op); | ||
| 130 | break; | ||
| 131 | } | ||
| 132 | } | ||
| 133 | } | ||
| 134 | continue; | ||
| 35 | } | 135 | } |
| 36 | _ => result.push(pick(before, after, &object)?.clone()), | 136 | } |
| 37 | }, | 137 | let space = match op { |
| 38 | (Some(before), None) => { | 138 | Op::Page { space, .. } => *space, |
| 39 | // Removed locally; keep only if the remote left it untouched. | 139 | Op::Section(_) => root, |
| 40 | if object != *before { | 140 | }; |
| 41 | return None; | 141 | if matches!(op, Op::Page { .. }) && converged.contains(&space) { |
| 142 | changed = true; | ||
| 143 | continue; | ||
| 144 | } | ||
| 145 | let mapped = match state.check(old, new, op)? { | ||
| 146 | Ok(Some(mapped)) => mapped, | ||
| 147 | Ok(None) => { | ||
| 148 | changed = true; | ||
| 149 | continue; | ||
| 42 | } | 150 | } |
| 151 | Err(kind) => return Ok(conflict(kind, space)), | ||
| 152 | }; | ||
| 153 | changed |= mapped != *op; | ||
| 154 | if let Some(kind) = state.apply(new, &queued.author, queued.edit.at, &mapped)? { | ||
| 155 | return Ok(conflict(kind, space)); | ||
| 43 | } | 156 | } |
| 44 | (None, _) => result.push(object.clone()), | 157 | ops.push(mapped); |
| 158 | } | ||
| 159 | if changed { | ||
| 160 | rewritten.push(( | ||
| 161 | queued.id, | ||
| 162 | Edit { | ||
| 163 | at: queued.edit.at, | ||
| 164 | ops, | ||
| 165 | }, | ||
| 166 | )); | ||
| 45 | } | 167 | } |
| 46 | } | 168 | } |
| 47 | for (index, object) in ours.objects.iter().enumerate() { | 169 | Ok(Outcome::Applied(rewritten)) |
| 48 | let id = object.id(); | 170 | } |
| 49 | if b.contains_key(&id) { | 171 | |
| 50 | if !t.contains_key(&id) { | 172 | fn order(section: &mut Section<'_>) -> Result<Order> { |
| 51 | // Removed remotely; a local edit to it cannot be placed. | 173 | Ok(section |
| 52 | if b[&id] != object { | 174 | .pages()? |
| 53 | return None; | 175 | .into_iter() |
| 176 | .map(|(space, _, level)| (space, level)) | ||
| 177 | .collect()) | ||
| 178 | } | ||
| 179 | |||
| 180 | /// Page spaces in section order with their levels. | ||
| 181 | type Order = Vec<(ExGuid, u32)>; | ||
| 182 | |||
| 183 | struct 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 | |||
| 195 | impl Replay { | ||
| 196 | /// Applies one op to the remote section; `Some` names why it did not apply. | ||
| 197 | fn apply( | ||
| 198 | &mut self, | ||
| 199 | new: &mut Section<'_>, | ||
| 200 | author: &str, | ||
| 201 | at: u64, | ||
| 202 | op: &Op, | ||
| 203 | ) -> Result<Option<ConflictKind>> { | ||
| 204 | match new.apply( | ||
| 205 | author, | ||
| 206 | &Edit { | ||
| 207 | at, | ||
| 208 | ops: vec![op.clone()], | ||
| 209 | }, | ||
| 210 | ) { | ||
| 211 | Ok(()) => { | ||
| 212 | if let Op::Section( | ||
| 213 | SectionOp::Create(creation) | SectionOp::Import { creation, .. }, | ||
| 214 | ) = op | ||
| 215 | { | ||
| 216 | self.created.insert(creation.space()); | ||
| 54 | } | 217 | } |
| 218 | Ok(None) | ||
| 55 | } | 219 | } |
| 56 | continue; | 220 | Err(OpError::Failed(error)) => Err(error.into()), |
| 221 | Err(error) => Ok(Some(match op { | ||
| 222 | // A page list the remote reordered refuses the op, however it says so. | ||
| 223 | Op::Section(_) => ConflictKind::StructureChanged, | ||
| 224 | Op::Page { .. } if matches!(error, OpError::Unsupported(_)) => { | ||
| 225 | ConflictKind::UnsupportedEdit | ||
| 226 | } | ||
| 227 | Op::Page { space, .. } if !self.listed(*space) => ConflictKind::TargetUnavailable, | ||
| 228 | Op::Page { .. } => ConflictKind::ContentChanged, | ||
| 229 | })), | ||
| 57 | } | 230 | } |
| 58 | if t.contains_key(&id) { | 231 | } |
| 59 | return None; | 232 | |
| 233 | /// The op as it replays on the remote section, or why it cannot. | ||
| 234 | fn check( | ||
| 235 | &mut self, | ||
| 236 | old: &mut Section<'_>, | ||
| 237 | new: &mut Section<'_>, | ||
| 238 | op: &Op, | ||
| 239 | ) -> Result<std::result::Result<Option<Op>, ConflictKind>> { | ||
| 240 | match op { | ||
| 241 | Op::Page { space, op } => { | ||
| 242 | if !self.listed(*space) { | ||
| 243 | return Ok(Err(ConflictKind::TargetUnavailable)); | ||
| 244 | } | ||
| 245 | let diff = match self.diffs.get_mut(space) { | ||
| 246 | Some(diff) => diff, | ||
| 247 | None => { | ||
| 248 | let diff = match (self.before.get(space), self.after.get(space)) { | ||
| 249 | (Some(before), Some(after)) if before != after => Some(Diff { | ||
| 250 | old: Index::of(&old.page(*space)?), | ||
| 251 | new: Index::of(&new.page(*space)?), | ||
| 252 | regions: BTreeMap::new(), | ||
| 253 | }), | ||
| 254 | _ => None, | ||
| 255 | }; | ||
| 256 | self.diffs.entry(*space).or_insert(diff) | ||
| 257 | } | ||
| 258 | }; | ||
| 259 | Ok(match diff { | ||
| 260 | None => Ok(Some(Op::Page { | ||
| 261 | space: *space, | ||
| 262 | op: op.clone(), | ||
| 263 | })), | ||
| 264 | Some(diff) => diff | ||
| 265 | .check(op) | ||
| 266 | .map(|op| op.map(|op| Op::Page { space: *space, op })), | ||
| 267 | }) | ||
| 268 | } | ||
| 269 | Op::Section(section) => Ok(self.section(section).map(|()| Some(op.clone()))), | ||
| 60 | } | 270 | } |
| 61 | let PageObject::Outline(_) = object else { | ||
| 62 | return None; | ||
| 63 | }; | ||
| 64 | let predecessor = ours.objects[..index] | ||
| 65 | .iter() | ||
| 66 | .rev() | ||
| 67 | .map(PageObject::id) | ||
| 68 | .find(|other| result.iter().any(|r| r.id() == *other)); | ||
| 69 | let at = match predecessor { | ||
| 70 | Some(other) => result.iter().position(|r| r.id() == other).unwrap() + 1, | ||
| 71 | None => 0, | ||
| 72 | }; | ||
| 73 | let at = at.min( | ||
| 74 | result | ||
| 75 | .iter() | ||
| 76 | .position(|r| matches!(r, PageObject::Title(_))) | ||
| 77 | .unwrap_or(result.len()), | ||
| 78 | ); | ||
| 79 | result.insert(at, object.clone()); | ||
| 80 | } | 271 | } |
| 81 | let order = |objects: &[PageObject], keep: &dyn Fn(ExGuid) -> bool| -> Vec<ExGuid> { | 272 | |
| 82 | objects | 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..]) | ||
| 83 | .iter() | 294 | .iter() |
| 84 | .filter(|object| matches!(object, PageObject::Outline(_)) && keep(object.id())) | 295 | .map(|(next, _)| Some(*next)) |
| 85 | .map(PageObject::id) | 296 | .filter(|next| next.is_some_and(|next| self.listed(next))) |
| 86 | .collect() | 297 | .chain([None]) |
| 87 | }; | 298 | .collect::<Vec<_>>(); |
| 88 | let common = |id: ExGuid| b.contains_key(&id) && o.contains_key(&id) && t.contains_key(&id); | 299 | let copy = page.copy()?; |
| 89 | let (base_order, our_order, their_order) = ( | 300 | let mut refusal = ConflictKind::StructureChanged; |
| 90 | order(&base.objects, &common), | 301 | for before in anchors { |
| 91 | order(&ours.objects, &common), | 302 | let creation = PageCreation::new(before, Some(&page.title), author)?; |
| 92 | order(&theirs.objects, &common), | 303 | let import = Op::Section(SectionOp::Import { |
| 93 | ); | 304 | creation: creation.clone(), |
| 94 | if our_order != base_order { | 305 | page: copy.clone(), |
| 95 | if their_order != base_order && their_order != our_order { | 306 | }); |
| 96 | return None; | 307 | match self.apply(new, author, at, &import)? { |
| 308 | Some(kind) => refusal = kind, | ||
| 309 | None => { | ||
| 310 | let mut ops = vec![import]; | ||
| 311 | if let Some(level) = position.map(|at| old[at].1).filter(|level| *level > 1) { | ||
| 312 | let indent = Op::Section(SectionOp::Pages(vec![PageEdit::set_level( | ||
| 313 | creation.space(), | ||
| 314 | level, | ||
| 315 | )?])); | ||
| 316 | if let Some(kind) = self.apply(new, author, at, &indent)? { | ||
| 317 | return Ok(Err(kind)); | ||
| 318 | } | ||
| 319 | ops.push(indent); | ||
| 320 | } | ||
| 321 | return Ok(Ok(ops)); | ||
| 322 | } | ||
| 323 | } | ||
| 97 | } | 324 | } |
| 98 | result = reorder(result, &our_order); | 325 | Ok(Err(refusal)) |
| 99 | } | 326 | } |
| 100 | // Definitions we added or changed travel with our paragraphs; theirs win elsewhere. | 327 | |
| 101 | let mut definitions = theirs.definitions.clone(); | 328 | fn section(&self, op: &SectionOp) -> std::result::Result<(), ConflictKind> { |
| 102 | for (id, definition) in &ours.definitions { | 329 | let (old, new) = &self.orders; |
| 103 | match (base.definitions.get(id), theirs.definitions.get(id)) { | 330 | let find = |
| 104 | (Some(before), Some(after)) if before != definition => { | 331 | |list: &[(ExGuid, u32)], space: ExGuid| list.iter().position(|(s, _)| *s == space); |
| 105 | if after != before && after != definition { | 332 | match op { |
| 106 | return None; | 333 | SectionOp::Create(_) | SectionOp::Import { .. } => Ok(()), |
| 334 | SectionOp::Delete(pages) => { | ||
| 335 | for page in pages { | ||
| 336 | if find(new, *page).is_none() && !self.created.contains(page) { | ||
| 337 | return Err(ConflictKind::TargetUnavailable); | ||
| 338 | } | ||
| 339 | if self.before.get(page) != self.after.get(page) { | ||
| 340 | return Err(ConflictKind::ContentChanged); | ||
| 341 | } | ||
| 107 | } | 342 | } |
| 108 | definitions.insert(*id, definition.clone()); | 343 | Ok(()) |
| 109 | } | 344 | } |
| 110 | (None, None) => { | 345 | SectionOp::Pages(edits) => { |
| 111 | definitions.insert(*id, definition.clone()); | 346 | // Where the batch puts each page, from the base order. |
| 347 | let mut wanted: Vec<ExGuid> = old.iter().map(|(space, _)| *space).collect(); | ||
| 348 | for edit in edits { | ||
| 349 | if let PagePosition::Before(before) = edit.position() | ||
| 350 | && let Some(at) = wanted.iter().position(|space| *space == edit.space()) | ||
| 351 | { | ||
| 352 | let space = wanted.remove(at); | ||
| 353 | let at = before | ||
| 354 | .and_then(|before| wanted.iter().position(|space| *space == before)) | ||
| 355 | .unwrap_or(wanted.len()); | ||
| 356 | wanted.insert(at, space); | ||
| 357 | } | ||
| 358 | } | ||
| 359 | let wanted: Vec<(ExGuid, u32)> = | ||
| 360 | wanted.into_iter().map(|space| (space, 0)).collect(); | ||
| 361 | for edit in edits { | ||
| 362 | let space = edit.space(); | ||
| 363 | let Some(at) = find(old, space) else { | ||
| 364 | continue; | ||
| 365 | }; | ||
| 366 | let Some(now) = find(new, space) else { | ||
| 367 | return Err(ConflictKind::TargetUnavailable); | ||
| 368 | }; | ||
| 369 | if old[at].1 != new[now].1 && new[now].1 != edit.level() { | ||
| 370 | return Err(ConflictKind::StructureChanged); | ||
| 371 | } | ||
| 372 | if edit.position() != PagePosition::Keep { | ||
| 373 | // Each page the remote also has must keep its side of the moved page, | ||
| 374 | // or already be on the side the batch wants. | ||
| 375 | let side = |list: &[(ExGuid, u32)], peer: ExGuid| { | ||
| 376 | Some(find(list, peer)? < find(list, space)?) | ||
| 377 | }; | ||
| 378 | let conflicting = old.iter().any(|(peer, _)| { | ||
| 379 | *peer != space | ||
| 380 | && find(new, *peer).is_some() | ||
| 381 | && side(new, *peer) != side(old, *peer) | ||
| 382 | && side(new, *peer) != side(&wanted, *peer) | ||
| 383 | }); | ||
| 384 | if conflicting { | ||
| 385 | return Err(ConflictKind::StructureChanged); | ||
| 386 | } | ||
| 387 | } | ||
| 388 | } | ||
| 389 | Ok(()) | ||
| 390 | } | ||
| 391 | } | ||
| 392 | } | ||
| 393 | |||
| 394 | /// Forms of a section op to try on the remote section list, in order: as it is, a | ||
| 395 | /// creation appended instead, moves and removals of the pages still there. | ||
| 396 | fn salvage(&self, new: &mut Section<'_>, op: &SectionOp) -> Vec<SectionOp> { | ||
| 397 | let pages: BTreeSet<ExGuid> = new | ||
| 398 | .pages() | ||
| 399 | .map(|pages| pages.into_iter().map(|(space, ..)| space).collect()) | ||
| 400 | .unwrap_or_default(); | ||
| 401 | let present = |space: &ExGuid| pages.contains(space) || self.created.contains(space); | ||
| 402 | match op { | ||
| 403 | SectionOp::Create(creation) => std::iter::once(creation.clone()) | ||
| 404 | .chain(creation.reposition(None).ok()) | ||
| 405 | .map(SectionOp::Create) | ||
| 406 | .collect(), | ||
| 407 | SectionOp::Import { .. } => vec![op.clone()], | ||
| 408 | SectionOp::Pages(edits) => { | ||
| 409 | let edits: Vec<PageEdit> = edits | ||
| 410 | .iter() | ||
| 411 | .filter(|edit| present(&edit.space())) | ||
| 412 | .cloned() | ||
| 413 | .collect(); | ||
| 414 | if edits.is_empty() { | ||
| 415 | Vec::new() | ||
| 416 | } else { | ||
| 417 | vec![SectionOp::Pages(edits)] | ||
| 418 | } | ||
| 419 | } | ||
| 420 | SectionOp::Delete(deleted) => { | ||
| 421 | let deleted: Vec<ExGuid> = deleted | ||
| 422 | .iter() | ||
| 423 | .filter(|space| present(space)) | ||
| 424 | .copied() | ||
| 425 | .collect(); | ||
| 426 | if deleted.is_empty() { | ||
| 427 | Vec::new() | ||
| 428 | } else { | ||
| 429 | vec![SectionOp::Delete(deleted)] | ||
| 430 | } | ||
| 112 | } | 431 | } |
| 113 | (None, Some(after)) if after != definition => return None, | ||
| 114 | _ => {} | ||
| 115 | } | 432 | } |
| 116 | } | 433 | } |
| 117 | Some(Page { | ||
| 118 | title: theirs.title.clone(), | ||
| 119 | identity: theirs.identity, | ||
| 120 | created: theirs.created, | ||
| 121 | margin_origin: theirs.margin_origin, | ||
| 122 | objects: result, | ||
| 123 | definitions, | ||
| 124 | }) | ||
| 125 | } | 434 | } |
| 126 | 435 | ||
| 127 | /// Places the objects named in `order` into the slots those objects occupy in `objects`, | 436 | /// One page as the base stored it and as the remote does, with the remote's text changes |
| 128 | /// leaving every other object where it is. | 437 | /// in the coordinates of the replayed edits. |
| 129 | fn reorder(objects: Vec<PageObject>, order: &[ExGuid]) -> Vec<PageObject> { | 438 | struct Diff { |
| 130 | let mut slots: Vec<Option<PageObject>> = objects.into_iter().map(Some).collect(); | 439 | old: Index, |
| 131 | let positions: Vec<usize> = slots | 440 | new: Index, |
| 132 | .iter() | 441 | regions: BTreeMap<ExGuid, Changes>, |
| 133 | .enumerate() | ||
| 134 | .filter(|(_, slot)| { | ||
| 135 | slot.as_ref() | ||
| 136 | .is_some_and(|object| order.contains(&object.id())) | ||
| 137 | }) | ||
| 138 | .map(|(i, _)| i) | ||
| 139 | .collect(); | ||
| 140 | let mut taken: BTreeMap<ExGuid, PageObject> = positions | ||
| 141 | .iter() | ||
| 142 | .map(|&i| { | ||
| 143 | let object = slots[i].take().unwrap(); | ||
| 144 | (object.id(), object) | ||
| 145 | }) | ||
| 146 | .collect(); | ||
| 147 | for (slot, id) in positions.into_iter().zip(order) { | ||
| 148 | slots[slot] = taken.remove(id); | ||
| 149 | } | ||
| 150 | slots.into_iter().flatten().collect() | ||
| 151 | } | 442 | } |
| 152 | 443 | ||
| 153 | fn pick<T: PartialEq + Clone>(base: &T, ours: &T, theirs: &T) -> Option<T> { | 444 | impl Diff { |
| 154 | if ours == base { | 445 | fn ours(&self, id: ExGuid) -> bool { |
| 155 | Some(theirs.clone()) | 446 | !self.old.nodes.contains_key(&id) |
| 156 | } else if theirs == base || theirs == ours { | ||
| 157 | Some(ours.clone()) | ||
| 158 | } else { | ||
| 159 | None | ||
| 160 | } | 447 | } |
| 161 | } | ||
| 162 | 448 | ||
| 163 | fn merge_outline(base: &Outline, ours: &Outline, theirs: &Outline) -> Option<Outline> { | 449 | /// Whether the remote left the object's own data as the base had it. |
| 164 | let title = pick(&base.title, &ours.title, &theirs.title)?; | 450 | fn kept(&self, id: ExGuid) -> bool { |
| 165 | let min_width = pick(&base.min_width, &ours.min_width, &theirs.min_width)?; | 451 | self.ours(id) |
| 166 | let indents = pick(&base.indents, &ours.indents, &theirs.indents)?; | 452 | || matches!((self.old.nodes.get(&id), self.new.nodes.get(&id)), (Some(a), Some(b)) if a.own == b.own) |
| 167 | let unsupported = pick(&base.unsupported, &ours.unsupported, &theirs.unsupported)?; | 453 | } |
| 168 | let mut layout = theirs.layout.clone(); | ||
| 169 | let (x, y) = pick( | ||
| 170 | &(base.layout.x, base.layout.y), | ||
| 171 | &(ours.layout.x, ours.layout.y), | ||
| 172 | &(theirs.layout.x, theirs.layout.y), | ||
| 173 | )?; | ||
| 174 | // A user-set width clears the wrap reservation, so the three travel together. | ||
| 175 | let width = |layout: &onestore::document::Layout| { | ||
| 176 | ( | ||
| 177 | layout.max_width, | ||
| 178 | layout.width_set_by_user, | ||
| 179 | layout.reserved_width, | ||
| 180 | ) | ||
| 181 | }; | ||
| 182 | let (max_width, user_set, reserved) = pick( | ||
| 183 | &width(&base.layout), | ||
| 184 | &width(&ours.layout), | ||
| 185 | &width(&theirs.layout), | ||
| 186 | )?; | ||
| 187 | layout.x = x; | ||
| 188 | layout.y = y; | ||
| 189 | layout.max_width = max_width; | ||
| 190 | layout.width_set_by_user = user_set; | ||
| 191 | layout.reserved_width = reserved; | ||
| 192 | Some(Outline { | ||
| 193 | id: theirs.id, | ||
| 194 | title, | ||
| 195 | min_width, | ||
| 196 | layout, | ||
| 197 | indents, | ||
| 198 | paragraphs: merge_paragraphs(&base.paragraphs, &ours.paragraphs, &theirs.paragraphs)?, | ||
| 199 | unsupported, | ||
| 200 | }) | ||
| 201 | } | ||
| 202 | 454 | ||
| 203 | type 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 | } | ||
| 204 | 468 | ||
| 205 | fn children(list: &[PageParagraph]) -> Children { | 469 | /// Whether the remote kept the object under the same parent, in the same order among |
| 206 | let mut children: Children = BTreeMap::new(); | 470 | /// the siblings both sides have. |
| 207 | for paragraph in list { | 471 | fn placed(&self, id: ExGuid) -> bool { |
| 208 | children | 472 | if self.ours(id) { |
| 209 | .entry(paragraph.parent) | 473 | return true; |
| 210 | .or_default() | 474 | } |
| 211 | .push(paragraph.id); | 475 | let (Some(a), Some(b)) = (self.old.nodes.get(&id), self.new.nodes.get(&id)) else { |
| 476 | return false; | ||
| 477 | }; | ||
| 478 | if a.parent != b.parent { | ||
| 479 | return false; | ||
| 480 | } | ||
| 481 | let siblings = |index: &Index| index.children(a.parent).to_vec(); | ||
| 482 | let (old, new) = (siblings(&self.old), siblings(&self.new)); | ||
| 483 | let at = |list: &[ExGuid], id: ExGuid| list.iter().position(|x| *x == id); | ||
| 484 | let (Some(i), Some(j)) = (at(&old, id), at(&new, id)) else { | ||
| 485 | return false; | ||
| 486 | }; | ||
| 487 | old.iter() | ||
| 488 | .enumerate() | ||
| 489 | .all(|(k, peer)| *peer == id || at(&new, *peer).is_none_or(|l| (k < i) == (l < j))) | ||
| 212 | } | 490 | } |
| 213 | children | ||
| 214 | } | ||
| 215 | 491 | ||
| 216 | fn merge_paragraphs( | 492 | /// Where the remote changed a text's characters and formats; its tags and date field |
| 217 | base: &[PageParagraph], | 493 | /// are not positions an op names. |
| 218 | ours: &[PageParagraph], | 494 | fn region(&mut self, text: ExGuid) -> std::result::Result<Option<&mut Changes>, ConflictKind> { |
| 219 | theirs: &[PageParagraph], | 495 | if !self.regions.contains_key(&text) { |
| 220 | ) -> Option<Vec<PageParagraph>> { | 496 | if self.ours(text) { |
| 221 | fn index(list: &[PageParagraph]) -> BTreeMap<ExGuid, &PageParagraph> { | 497 | return Ok(None); |
| 222 | list.iter().map(|p| (p.id, p)).collect() | 498 | } |
| 223 | } | 499 | let (Some(Own::Text(a)), Some(Own::Text(b))) = ( |
| 224 | let (b, o, t) = (index(base), index(ours), index(theirs)); | 500 | self.old.nodes.get(&text).map(|node| &node.own), |
| 225 | let mut merged: BTreeMap<ExGuid, PageParagraph> = BTreeMap::new(); | 501 | self.new.nodes.get(&text).map(|node| &node.own), |
| 226 | let mut removed = BTreeSet::new(); | 502 | ) else { |
| 227 | for id in b.keys().chain(o.keys()).chain(t.keys()) { | 503 | return Err(ConflictKind::ContentChanged); |
| 228 | if merged.contains_key(id) || removed.contains(id) { | 504 | }; |
| 229 | continue; | 505 | if a.text == b.text { |
| 506 | return Ok(None); | ||
| 507 | } | ||
| 508 | let region = Changes::between(&a.text, &b.text); | ||
| 509 | self.regions.insert(text, region); | ||
| 230 | } | 510 | } |
| 231 | let paragraph = match (b.get(id), o.get(id), t.get(id)) { | 511 | Ok(self.regions.get_mut(&text)) |
| 232 | (Some(x), Some(y), Some(z)) => merge_paragraph(x, y, z)?, | 512 | } |
| 233 | (Some(x), None, Some(z)) => { | 513 | |
| 234 | if z != x { | 514 | /// The op as it replays on the remote page; `None` when the remote already made it. |
| 235 | return None; | 515 | fn check(&mut self, op: &PageOp) -> std::result::Result<Option<PageOp>, ConflictKind> { |
| 516 | use ConflictKind::ContentChanged as Changed; | ||
| 517 | let require = |ok: bool| if ok { Ok(()) } else { Err(Changed) }; | ||
| 518 | let mut op = op.clone(); | ||
| 519 | match &mut op { | ||
| 520 | PageOp::Text { text, range, with } => { | ||
| 521 | if let Some(region) = self.region(*text)? { | ||
| 522 | let mapped = region.map(range).ok_or(Changed)?; | ||
| 523 | region.replaced(range, with.encode_utf16().count() as u32); | ||
| 524 | *range = mapped; | ||
| 236 | } | 525 | } |
| 237 | removed.insert(*id); | ||
| 238 | continue; | ||
| 239 | } | 526 | } |
| 240 | (Some(x), Some(y), None) => { | 527 | PageOp::Format { text, range, .. } => { |
| 241 | if y != x { | 528 | if let Some(region) = self.region(*text)? { |
| 242 | return None; | 529 | *range = region.map(range).ok_or(Changed)?; |
| 243 | } | 530 | } |
| 244 | removed.insert(*id); | ||
| 245 | continue; | ||
| 246 | } | 531 | } |
| 247 | (Some(_), None, None) => { | 532 | PageOp::Link { text, .. } | PageOp::Equation { text, .. } => { |
| 248 | removed.insert(*id); | 533 | require(self.region(*text)?.is_none())?; |
| 249 | continue; | ||
| 250 | } | 534 | } |
| 251 | (None, Some(_), Some(_)) => return None, | 535 | PageOp::Split { |
| 252 | (None, Some(y), None) => (*y).clone(), | 536 | text, at, right, .. |
| 253 | (None, None, Some(z)) => (*z).clone(), | 537 | } => { |
| 254 | (None, None, None) => unreachable!(), | 538 | // The new paragraph copies the holder's placement and formats; lists and tags |
| 255 | }; | 539 | // stay with the holder unless the split names copies of them. |
| 256 | merged.insert(*id, paragraph); | 540 | let holder = self.holder(*text); |
| 257 | } | 541 | require(holder.is_none_or(|holder| self.splits_alike(holder)))?; |
| 258 | // Child order per container: the remote order with our repositioned paragraphs re-placed. | 542 | if let Some(region) = self.region(*text)? { |
| 259 | let (bc, oc, tc) = (children(base), children(ours), children(theirs)); | 543 | // The cut moves with the character after it: text the remote typed at |
| 260 | // A paragraph we added after one they gave new children would sit after or among those | 544 | // the cut stays left of it. |
| 261 | // children; Enter splitting a paragraph carries its children below, so neither is assumed. | 545 | let cut = region.map(&(*at..*at + 1)).ok_or(Changed)?; |
| 262 | for pair in oc.values().flat_map(|list| list.windows(2)) { | 546 | let right_changes = region.split(*at); |
| 263 | if b.contains_key(&pair[0]) | 547 | if !right_changes.0.is_empty() { |
| 264 | && !b.contains_key(&pair[1]) | 548 | self.regions.insert(*right, right_changes); |
| 265 | && tc | 549 | } |
| 266 | .get(&Some(pair[0])) | 550 | *at = cut.start; |
| 267 | .is_some_and(|children| children.iter().any(|id| !b.contains_key(id))) | 551 | } |
| 268 | { | 552 | } |
| 269 | return None; | 553 | PageOp::Join { left, right } => { |
| 554 | require(self.untouched(*right))?; | ||
| 555 | require( | ||
| 556 | self.holder_text(*right) | ||
| 557 | .is_none_or(|text| !self.regions.contains_key(&text)), | ||
| 558 | )?; | ||
| 559 | if let Some(text) = self.holder_text(*left) { | ||
| 560 | self.region(text)?; | ||
| 561 | } | ||
| 562 | } | ||
| 563 | PageOp::Insert { .. } | PageOp::Add { .. } => {} | ||
| 564 | PageOp::Move { | ||
| 565 | object, | ||
| 566 | parent, | ||
| 567 | before, | ||
| 568 | } => require(self.placed(*object) || self.placed_at(*object, *parent, *before))?, | ||
| 569 | PageOp::Delete { object } => { | ||
| 570 | if !self.ours(*object) && !self.new.nodes.contains_key(object) { | ||
| 571 | return Ok(None); | ||
| 572 | } | ||
| 573 | require(self.untouched(*object))? | ||
| 574 | } | ||
| 575 | PageOp::Level { paragraph, .. } => require( | ||
| 576 | self.ours(*paragraph) || { | ||
| 577 | let level = |index: &Index| { | ||
| 578 | index.nodes.get(paragraph).map(|node| { | ||
| 579 | ( | ||
| 580 | node.parent, | ||
| 581 | match &node.own { | ||
| 582 | Own::Paragraph { level, .. } => *level, | ||
| 583 | _ => 0, | ||
| 584 | }, | ||
| 585 | ) | ||
| 586 | }) | ||
| 587 | }; | ||
| 588 | level(&self.old).is_some() && level(&self.old) == level(&self.new) | ||
| 589 | }, | ||
| 590 | )?, | ||
| 591 | PageOp::Outline { object, edit } => { | ||
| 592 | if self.ours(*object) { | ||
| 593 | return Ok(Some(op)); | ||
| 594 | } | ||
| 595 | let edit: &OutlineEdit = edit; | ||
| 596 | // What the edit sets, as each side has it. | ||
| 597 | let part = |index: &Index| { | ||
| 598 | index.nodes.get(object).map(|node| match (&node.own, edit) { | ||
| 599 | (Own::Outline { layout, .. }, OutlineEdit::Position { .. }) => { | ||
| 600 | (layout.x, layout.y, None, None) | ||
| 601 | } | ||
| 602 | (Own::Outline { layout, .. }, OutlineEdit::Width { .. }) => ( | ||
| 603 | layout.max_width, | ||
| 604 | layout.reserved_width, | ||
| 605 | layout.width_set_by_user, | ||
| 606 | None, | ||
| 607 | ), | ||
| 608 | (Own::Paragraph { collapsed, .. }, OutlineEdit::Collapsed(_)) => { | ||
| 609 | (None, None, None, Some(*collapsed)) | ||
| 610 | } | ||
| 611 | _ => (None, None, None, None), | ||
| 612 | }) | ||
| 613 | }; | ||
| 614 | let (before, after) = (part(&self.old), part(&self.new)); | ||
| 615 | if before.is_some() && before != after { | ||
| 616 | // The remote set the same value: the edit is done. | ||
| 617 | let near = |a: Option<f32>, b: f32| a.is_some_and(|a| (a - b).abs() < 1e-3); | ||
| 618 | let done = match (after, edit) { | ||
| 619 | (Some((x, y, ..)), OutlineEdit::Position { x: px, y: py }) => { | ||
| 620 | near(x, *px) && near(y, *py) | ||
| 621 | } | ||
| 622 | ( | ||
| 623 | Some((width, reserved, set, _)), | ||
| 624 | OutlineEdit::Width { points, user_set }, | ||
| 625 | ) => near(width, *points) && reserved.is_none() && set == Some(*user_set), | ||
| 626 | (Some((.., collapsed)), OutlineEdit::Collapsed(value)) => { | ||
| 627 | collapsed == Some(*value) | ||
| 628 | } | ||
| 629 | _ => false, | ||
| 630 | }; | ||
| 631 | return if done { Ok(None) } else { Err(Changed) }; | ||
| 632 | } | ||
| 633 | require(before.is_some())? | ||
| 634 | } | ||
| 635 | PageOp::Paragraph { paragraph, .. } | ||
| 636 | | PageOp::Style { paragraph, .. } | ||
| 637 | | PageOp::List { paragraph, .. } => { | ||
| 638 | require(self.kept(*paragraph) && self.new_has(*paragraph))? | ||
| 639 | } | ||
| 640 | PageOp::Tags { target, .. } => require(self.same_tags(*target))?, | ||
| 641 | PageOp::Picture { | ||
| 642 | picture: object, .. | ||
| 643 | } | ||
| 644 | | PageOp::Attachment { | ||
| 645 | attachment: object, .. | ||
| 646 | } | ||
| 647 | | PageOp::Strokes { ink: object, .. } => { | ||
| 648 | require(self.kept(*object) && self.new_has(*object))? | ||
| 649 | } | ||
| 650 | PageOp::Table { table, edit } => match edit { | ||
| 651 | TableEdit::Cell { cell, .. } => require(self.kept(*cell) && self.new_has(*cell))?, | ||
| 652 | TableEdit::DeleteRow(row) => require(self.kept(*table) && self.untouched(*row))?, | ||
| 653 | TableEdit::DeleteColumn(_) => require(self.untouched(*table))?, | ||
| 654 | TableEdit::Rows { .. } | ||
| 655 | | TableEdit::Column { .. } | ||
| 656 | | TableEdit::Columns(_) | ||
| 657 | | TableEdit::Borders(_) => require(self.kept(*table) && self.new_has(*table))?, | ||
| 658 | }, | ||
| 270 | } | 659 | } |
| 660 | Ok(Some(op)) | ||
| 271 | } | 661 | } |
| 272 | let mut order: BTreeMap<Option<ExGuid>, Vec<ExGuid>> = BTreeMap::new(); | 662 | |
| 273 | let containers: BTreeSet<Option<ExGuid>> = bc | 663 | /// Whether the remote put the object where a move places it: under `parent` (the page |
| 274 | .keys() | 664 | /// when `None`), before `before` or last. |
| 275 | .chain(oc.keys()) | 665 | fn placed_at(&self, id: ExGuid, parent: Option<ExGuid>, before: Option<ExGuid>) -> bool { |
| 276 | .chain(tc.keys()) | 666 | let Some(node) = self.new.nodes.get(&id) else { |
| 277 | .copied() | 667 | return false; |
| 278 | .collect(); | ||
| 279 | for container in containers { | ||
| 280 | let live = |ids: Option<&Vec<ExGuid>>| -> Vec<ExGuid> { | ||
| 281 | ids.map(|ids| { | ||
| 282 | ids.iter() | ||
| 283 | .copied() | ||
| 284 | .filter(|id| merged.contains_key(id)) | ||
| 285 | .collect() | ||
| 286 | }) | ||
| 287 | .unwrap_or_default() | ||
| 288 | }; | 668 | }; |
| 289 | let (our_list, their_list) = (live(oc.get(&container)), live(tc.get(&container))); | 669 | let container = parent.unwrap_or(PAGE); |
| 290 | let of_base = |ids: Option<&Vec<ExGuid>>| -> Vec<ExGuid> { | 670 | let siblings = self.new.children(container); |
| 291 | ids.map(|ids| { | 671 | node.parent == container |
| 292 | ids.iter() | 672 | && siblings |
| 293 | .copied() | 673 | .iter() |
| 294 | .filter(|id| b.contains_key(id)) | 674 | .position(|sibling| *sibling == id) |
| 295 | .collect() | 675 | .is_some_and(|at| siblings.get(at + 1).copied() == before) |
| 676 | } | ||
| 677 | |||
| 678 | /// Whether the remote kept a paragraph's, text's or table's tags. | ||
| 679 | fn same_tags(&self, id: ExGuid) -> bool { | ||
| 680 | if self.ours(id) { | ||
| 681 | return true; | ||
| 682 | } | ||
| 683 | let tags = |index: &Index| { | ||
| 684 | index.nodes.get(&id).map(|node| match &node.own { | ||
| 685 | Own::Paragraph { tags, .. } | Own::Table { tags, .. } => Some(tags.clone()), | ||
| 686 | Own::Text(text) => Some(text.tags.clone()), | ||
| 687 | _ => None, | ||
| 296 | }) | 688 | }) |
| 297 | .unwrap_or_default() | ||
| 298 | }; | 689 | }; |
| 299 | let (base_ids, our_ids, their_ids) = ( | 690 | let before = tags(&self.old); |
| 300 | of_base(bc.get(&container)), | 691 | before.is_some() && before == tags(&self.new) |
| 301 | of_base(oc.get(&container)), | 692 | } |
| 302 | of_base(tc.get(&container)), | 693 | |
| 303 | ); | 694 | fn new_has(&self, id: ExGuid) -> bool { |
| 304 | let ours_moved = if our_ids == base_ids || our_ids == their_ids { | 695 | self.ours(id) || self.new.nodes.contains_key(&id) |
| 305 | BTreeSet::new() | 696 | } |
| 306 | } else { | 697 | |
| 307 | moved(&base_ids, &our_ids) | 698 | /// The paragraph holding a text, as the base stored it. |
| 699 | fn holder(&self, text: ExGuid) -> Option<ExGuid> { | ||
| 700 | self.old.nodes.get(&text).map(|node| node.parent) | ||
| 701 | } | ||
| 702 | |||
| 703 | fn holder_text(&self, paragraph: ExGuid) -> Option<ExGuid> { | ||
| 704 | match self.old.nodes.get(&paragraph).map(|node| &node.own) { | ||
| 705 | Some(Own::Paragraph { content, .. }) => Some(*content), | ||
| 706 | _ => None, | ||
| 707 | } | ||
| 708 | } | ||
| 709 | |||
| 710 | /// Whether a split of the paragraph makes the same new paragraph on both sides: its | ||
| 711 | /// container, style, format and children are as the base had them. | ||
| 712 | fn splits_alike(&self, id: ExGuid) -> bool { | ||
| 713 | if self.ours(id) { | ||
| 714 | return true; | ||
| 715 | } | ||
| 716 | let shape = |index: &Index| { | ||
| 717 | index.nodes.get(&id).map(|node| match &node.own { | ||
| 718 | Own::Paragraph { style, format, .. } => { | ||
| 719 | Some((node.parent, *style, format.clone(), node.children.clone())) | ||
| 720 | } | ||
| 721 | _ => None, | ||
| 722 | }) | ||
| 308 | }; | 723 | }; |
| 309 | // A paragraph we repositioned that the remote also moved, reparented or removed | 724 | let before = shape(&self.old); |
| 310 | // has two destinations; neither side's placement can be assumed. | 725 | before.is_some() && before == shape(&self.new) |
| 311 | let mut theirs_touched = moved(&base_ids, &their_ids); | 726 | } |
| 312 | theirs_touched.extend(base_ids.iter().filter(|id| !their_ids.contains(id))); | 727 | } |
| 313 | if ours_moved.iter().any(|id| theirs_touched.contains(id)) { | 728 | |
| 314 | return None; | 729 | /// Where the remote replaced parts of a text, in order: each `start..end` of the text as |
| 730 | /// the replayed edits see it holds what the remote replaced by `end - start + delta` UTF-16 | ||
| 731 | /// units. Outside them both texts hold the same characters in the same formats. | ||
| 732 | #[derive(Debug, Clone, Default, PartialEq)] | ||
| 733 | struct Changes(Vec<Region>); | ||
| 734 | |||
| 735 | #[derive(Debug, Clone, Copy, PartialEq)] | ||
| 736 | struct 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. | ||
| 743 | fn units(paragraph: &Paragraph) -> Vec<(char, Format)> { | ||
| 744 | let mut out = Vec::new(); | ||
| 745 | let mut spans = paragraph.spans().iter(); | ||
| 746 | let mut span = spans.next(); | ||
| 747 | for (byte, character) in paragraph.text().char_indices() { | ||
| 748 | while span.is_some_and(|span| span.end <= byte) { | ||
| 749 | span = spans.next(); | ||
| 315 | } | 750 | } |
| 316 | let mut list: Vec<ExGuid> = their_list.clone(); | 751 | out.push(( |
| 317 | list.retain(|id| merged[id].parent == container); | 752 | character, |
| 318 | // Paragraphs we repositioned, added or brought here follow their predecessor in our list. | 753 | span.map(|span| span.format.clone()).unwrap_or_default(), |
| 319 | for (at, id) in our_list.iter().enumerate() { | 754 | )); |
| 320 | if merged[id].parent != container || (list.contains(id) && !ours_moved.contains(id)) { | 755 | } |
| 321 | continue; | 756 | out |
| 757 | } | ||
| 758 | |||
| 759 | /// Beyond this many cells of the alignment table the changed middle counts as one change. | ||
| 760 | const CELLS: usize = 4_000_000; | ||
| 761 | |||
| 762 | impl Changes { | ||
| 763 | /// The runs where `new` differs from `old` in characters or formats, from a longest | ||
| 764 | /// common subsequence of the two: where several align equally, one is chosen, which | ||
| 765 | /// places an op among repeated characters one way of the equally valid ones. | ||
| 766 | fn between(old: &Paragraph, new: &Paragraph) -> Self { | ||
| 767 | let (a, b) = (units(old), units(new)); | ||
| 768 | let prefix = a.iter().zip(&b).take_while(|(x, y)| x == y).count(); | ||
| 769 | let suffix = a[prefix..] | ||
| 770 | .iter() | ||
| 771 | .rev() | ||
| 772 | .zip(b[prefix..].iter().rev()) | ||
| 773 | .take_while(|(x, y)| x == y) | ||
| 774 | .count(); | ||
| 775 | let (x, y) = (&a[prefix..a.len() - suffix], &b[prefix..b.len() - suffix]); | ||
| 776 | // Matched pairs of the middles, in order. | ||
| 777 | let mut matched = Vec::new(); | ||
| 778 | if x.len().saturating_mul(y.len()) <= CELLS { | ||
| 779 | let width = y.len() + 1; | ||
| 780 | let mut table = vec![0_u32; (x.len() + 1) * width]; | ||
| 781 | for i in (0..x.len()).rev() { | ||
| 782 | for j in (0..y.len()).rev() { | ||
| 783 | table[i * width + j] = if x[i] == y[j] { | ||
| 784 | table[(i + 1) * width + j + 1] + 1 | ||
| 785 | } else { | ||
| 786 | table[(i + 1) * width + j].max(table[i * width + j + 1]) | ||
| 787 | }; | ||
| 788 | } | ||
| 322 | } | 789 | } |
| 323 | list.retain(|other| other != id); | 790 | let (mut i, mut j) = (0, 0); |
| 324 | let predecessor = our_list[..at] | 791 | while i < x.len() && j < y.len() { |
| 792 | if x[i] == y[j] { | ||
| 793 | matched.push((i, j)); | ||
| 794 | i += 1; | ||
| 795 | j += 1; | ||
| 796 | } else if table[(i + 1) * width + j] >= table[i * width + j + 1] { | ||
| 797 | i += 1; | ||
| 798 | } else { | ||
| 799 | j += 1; | ||
| 800 | } | ||
| 801 | } | ||
| 802 | } | ||
| 803 | matched.push((x.len(), y.len())); | ||
| 804 | let width = |units: &[(char, Format)]| -> u32 { | ||
| 805 | units | ||
| 325 | .iter() | 806 | .iter() |
| 326 | .rev() | 807 | .map(|(character, _)| character.len_utf16() as u32) |
| 327 | .find(|other| list.contains(other)); | 808 | .sum() |
| 328 | let position = predecessor.map_or(0, |p| list.iter().position(|x| x == p).unwrap() + 1); | 809 | }; |
| 329 | list.insert(position, *id); | 810 | let mut regions = Vec::new(); |
| 811 | let (mut at, mut i, mut j) = (width(&a[..prefix]), 0, 0); | ||
| 812 | for (k, l) in matched { | ||
| 813 | if (k, l) != (i, j) { | ||
| 814 | let (removed, inserted) = (width(&x[i..k]), width(&y[j..l])); | ||
| 815 | regions.push(Region { | ||
| 816 | start: at, | ||
| 817 | end: at + removed, | ||
| 818 | delta: i64::from(inserted) - i64::from(removed), | ||
| 819 | }); | ||
| 820 | at += removed; | ||
| 821 | } | ||
| 822 | if k < x.len() { | ||
| 823 | at += width(&x[k..k + 1]); | ||
| 824 | } | ||
| 825 | (i, j) = (k + 1, l + 1); | ||
| 330 | } | 826 | } |
| 331 | order.insert(container, list); | 827 | Self(regions) |
| 332 | } | 828 | } |
| 333 | let mut out = Vec::new(); | 829 | |
| 334 | let mut pending: Vec<ExGuid> = order.get(&None).cloned().unwrap_or_default(); | 830 | /// The range on the remote text, when it lies clear of every change: a range may end |
| 335 | pending.reverse(); | 831 | /// where a change starts or start where one ends, an insertion point may not. |
| 336 | while let Some(id) = pending.pop() { | 832 | fn map(&self, range: &Range<u32>) -> Option<Range<u32>> { |
| 337 | let paragraph = merged.remove(&id)?; | 833 | let empty = range.start == range.end; |
| 338 | out.push(paragraph); | 834 | let mut shift = 0; |
| 339 | if let Some(children) = order.get(&Some(id)) { | 835 | for region in &self.0 { |
| 340 | pending.extend(children.iter().rev().copied()); | 836 | if range.end < region.start || (!empty && range.end == region.start) { |
| 837 | break; | ||
| 838 | } | ||
| 839 | if range.start > region.end || (!empty && range.start == region.end) { | ||
| 840 | shift += region.delta; | ||
| 841 | } else { | ||
| 842 | return None; | ||
| 843 | } | ||
| 844 | } | ||
| 845 | let shifted = |at: u32| u32::try_from(i64::from(at) + shift).ok(); | ||
| 846 | Some(shifted(range.start)?..shifted(range.end)?) | ||
| 847 | } | ||
| 848 | |||
| 849 | /// Follows a replacement of `range`, clear of every change, by `length` units. | ||
| 850 | fn replaced(&mut self, range: &Range<u32>, length: u32) { | ||
| 851 | let moved = i64::from(length) - i64::from(range.end - range.start); | ||
| 852 | for region in &mut self.0 { | ||
| 853 | if region.start >= range.end { | ||
| 854 | region.start = (i64::from(region.start) + moved) as u32; | ||
| 855 | region.end = (i64::from(region.end) + moved) as u32; | ||
| 856 | } | ||
| 341 | } | 857 | } |
| 342 | } | 858 | } |
| 343 | if !merged.is_empty() { | 859 | |
| 344 | return None; | 860 | /// Cuts the text at `at`, clear of every change, keeping the changes left of it (text |
| 861 | /// typed at the cut included) and returning those right of it, measured from the cut. | ||
| 862 | fn split(&mut self, at: u32) -> Self { | ||
| 863 | let right = self | ||
| 864 | .0 | ||
| 865 | .iter() | ||
| 866 | .filter(|region| region.end > at) | ||
| 867 | .map(|region| Region { | ||
| 868 | start: region.start - at, | ||
| 869 | end: region.end - at, | ||
| 870 | delta: region.delta, | ||
| 871 | }) | ||
| 872 | .collect(); | ||
| 873 | self.0.retain(|region| region.end <= at); | ||
| 874 | Self(right) | ||
| 345 | } | 875 | } |
| 346 | Some(out) | ||
| 347 | } | 876 | } |
| 348 | 877 | ||
| 349 | /// The members of `list` outside a longest common subsequence with `base`: the paragraphs | 878 | /// What an object holds apart from the objects under it. |
| 350 | /// a reorder of the shared members had to move. | 879 | #[derive(Debug, Clone, PartialEq)] |
| 351 | fn moved(base: &[ExGuid], list: &[ExGuid]) -> BTreeSet<ExGuid> { | 880 | enum Own { |
| 352 | let a: Vec<ExGuid> = base | 881 | Outline { |
| 353 | .iter() | 882 | title: bool, |
| 354 | .copied() | 883 | min_width: Option<f32>, |
| 355 | .filter(|id| list.contains(id)) | 884 | layout: Layout, |
| 356 | .collect(); | 885 | indents: Vec<f32>, |
| 357 | let b: Vec<ExGuid> = list | 886 | unsupported: Vec<Unsupported>, |
| 358 | .iter() | 887 | }, |
| 359 | .copied() | 888 | Title { |
| 360 | .filter(|id| base.contains(id)) | 889 | date: Option<ExGuid>, |
| 361 | .collect(); | 890 | layout: Layout, |
| 362 | let mut table = vec![vec![0usize; b.len() + 1]; a.len() + 1]; | 891 | }, |
| 363 | for i in (0..a.len()).rev() { | 892 | Paragraph { |
| 364 | for j in (0..b.len()).rev() { | 893 | level: u32, |
| 365 | table[i][j] = if a[i] == b[j] { | 894 | style: Option<ExGuid>, |
| 366 | table[i + 1][j + 1] + 1 | 895 | format: Format, |
| 367 | } else { | 896 | lists: Vec<ExGuid>, |
| 368 | table[i + 1][j].max(table[i][j + 1]) | 897 | tags: Vec<Tag>, |
| 369 | }; | 898 | media: MediaIndex, |
| 899 | collapsed: bool, | ||
| 900 | content: ExGuid, | ||
| 901 | }, | ||
| 902 | Text(TextObject), | ||
| 903 | Table { | ||
| 904 | columns: Vec<TableColumn>, | ||
| 905 | borders: Option<bool>, | ||
| 906 | layout: Layout, | ||
| 907 | tags: Vec<Tag>, | ||
| 908 | }, | ||
| 909 | Row, | ||
| 910 | Cell { | ||
| 911 | layout: Layout, | ||
| 912 | indents: Vec<f32>, | ||
| 913 | shading: Option<u32>, | ||
| 914 | unsupported: Vec<Unsupported>, | ||
| 915 | }, | ||
| 916 | Image(Image), | ||
| 917 | Attachment(Attachment), | ||
| 918 | Ink(Ink), | ||
| 919 | Unsupported(Unsupported), | ||
| 920 | } | ||
| 921 | |||
| 922 | #[derive(Debug)] | ||
| 923 | struct Node { | ||
| 924 | own: Own, | ||
| 925 | /// The containing object; the page is the default identity. | ||
| 926 | parent: ExGuid, | ||
| 927 | children: Vec<ExGuid>, | ||
| 928 | } | ||
| 929 | |||
| 930 | impl 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)] | ||
| 942 | struct Index { | ||
| 943 | nodes: BTreeMap<ExGuid, Node>, | ||
| 944 | page: Vec<ExGuid>, | ||
| 945 | } | ||
| 946 | |||
| 947 | const PAGE: ExGuid = ExGuid { | ||
| 948 | guid: [0; 16], | ||
| 949 | n: 0, | ||
| 950 | }; | ||
| 951 | |||
| 952 | impl Index { | ||
| 953 | fn of(page: &Page) -> Self { | ||
| 954 | let mut index = Self::default(); | ||
| 955 | for object in &page.objects { | ||
| 956 | index.page.push(object.id()); | ||
| 957 | match object { | ||
| 958 | PageObject::Outline(outline) => index.outline(outline, PAGE), | ||
| 959 | PageObject::Title(title) => { | ||
| 960 | index.add( | ||
| 961 | title.id, | ||
| 962 | PAGE, | ||
| 963 | Own::Title { | ||
| 964 | date: title.date, | ||
| 965 | layout: title.layout.clone(), | ||
| 966 | }, | ||
| 967 | title.outlines.iter().map(|outline| outline.id).collect(), | ||
| 968 | ); | ||
| 969 | for outline in &title.outlines { | ||
| 970 | index.outline(outline, title.id); | ||
| 971 | } | ||
| 972 | } | ||
| 973 | PageObject::Image(image) => { | ||
| 974 | index.add(image.id, PAGE, Own::Image(image.clone()), Vec::new()) | ||
| 975 | } | ||
| 976 | PageObject::Ink(ink) => index.add(ink.id, PAGE, Own::Ink(ink.clone()), Vec::new()), | ||
| 977 | PageObject::Unsupported(object) => index.add( | ||
| 978 | object.id, | ||
| 979 | PAGE, | ||
| 980 | Own::Unsupported(object.clone()), | ||
| 981 | Vec::new(), | ||
| 982 | ), | ||
| 983 | } | ||
| 370 | } | 984 | } |
| 985 | index | ||
| 371 | } | 986 | } |
| 372 | let mut kept = BTreeSet::new(); | 987 | |
| 373 | let (mut i, mut j) = (0, 0); | 988 | fn children(&self, id: ExGuid) -> &[ExGuid] { |
| 374 | while i < a.len() && j < b.len() { | 989 | if id == PAGE { |
| 375 | if a[i] == b[j] { | 990 | &self.page |
| 376 | kept.insert(a[i]); | ||
| 377 | i += 1; | ||
| 378 | j += 1; | ||
| 379 | } else if table[i + 1][j] >= table[i][j + 1] { | ||
| 380 | i += 1; | ||
| 381 | } else { | 991 | } else { |
| 382 | j += 1; | 992 | self.nodes.get(&id).map_or(&[], |node| &node.children) |
| 383 | } | 993 | } |
| 384 | } | 994 | } |
| 385 | b.into_iter().filter(|id| !kept.contains(id)).collect() | ||
| 386 | } | ||
| 387 | 995 | ||
| 388 | fn merge_paragraph( | 996 | fn add(&mut self, id: ExGuid, parent: ExGuid, own: Own, children: Vec<ExGuid>) { |
| 389 | base: &PageParagraph, | 997 | self.nodes.insert( |
| 390 | ours: &PageParagraph, | 998 | id, |
| 391 | theirs: &PageParagraph, | 999 | Node { |
| 392 | ) -> Option<PageParagraph> { | 1000 | own, |
| 393 | let lists = pick(&base.lists, &ours.lists, &theirs.lists)?; | 1001 | parent, |
| 394 | let tags = pick(&base.tags, &ours.tags, &theirs.tags)?; | 1002 | children, |
| 395 | let style = pick(&base.style, &ours.style, &theirs.style)?; | 1003 | }, |
| 396 | let format = pick(&base.format, &ours.format, &theirs.format)?; | 1004 | ); |
| 397 | let (parent, level) = pick( | 1005 | } |
| 398 | &(base.parent, base.level), | ||
| 399 | &(ours.parent, ours.level), | ||
| 400 | &(theirs.parent, theirs.level), | ||
| 401 | )?; | ||
| 402 | let collapsed = pick(&base.collapsed, &ours.collapsed, &theirs.collapsed)?; | ||
| 403 | let content = match pick(&base.content, &ours.content, &theirs.content) { | ||
| 404 | Some(picked) => picked, | ||
| 405 | None => match (&base.content, &ours.content, &theirs.content) { | ||
| 406 | (ParagraphContent::Text(x), ParagraphContent::Text(y), ParagraphContent::Text(z)) | ||
| 407 | if x.id == y.id && x.id == z.id => | ||
| 408 | { | ||
| 409 | let mut text = z.clone(); | ||
| 410 | text.date_field = pick(&x.date_field, &y.date_field, &z.date_field)?; | ||
| 411 | text.tags = pick(&x.tags, &y.tags, &z.tags)?; | ||
| 412 | text.text = merge_text(&x.text, &y.text, &z.text)?; | ||
| 413 | ParagraphContent::Text(text) | ||
| 414 | } | ||
| 415 | _ => return None, | ||
| 416 | }, | ||
| 417 | }; | ||
| 418 | Some(PageParagraph { | ||
| 419 | id: theirs.id, | ||
| 420 | parent, | ||
| 421 | level, | ||
| 422 | style, | ||
| 423 | format, | ||
| 424 | content, | ||
| 425 | lists, | ||
| 426 | tags, | ||
| 427 | media: pick(&base.media, &ours.media, &theirs.media)?, | ||
| 428 | collapsed, | ||
| 429 | }) | ||
| 430 | } | ||
| 431 | 1006 | ||
| 432 | /// Re-applies our replacement inside the remote text when its range maps unambiguously. | 1007 | fn outline(&mut self, outline: &Outline, parent: ExGuid) { |
| 433 | fn merge_text( | 1008 | let children = self.paragraphs(&outline.paragraphs, outline.id); |
| 434 | base: &onestore::page::Paragraph, | 1009 | self.add( |
| 435 | ours: &onestore::page::Paragraph, | 1010 | outline.id, |
| 436 | theirs: &onestore::page::Paragraph, | 1011 | parent, |
| 437 | ) -> Option<onestore::page::Paragraph> { | 1012 | Own::Outline { |
| 438 | if let Some(picked) = pick(base, ours, theirs) { | 1013 | title: outline.title, |
| 439 | return Some(picked); | 1014 | min_width: outline.min_width, |
| 440 | } | 1015 | layout: outline.layout.clone(), |
| 441 | if base.text() == ours.text() { | 1016 | indents: outline.indents.clone(), |
| 442 | // A local formatting-only change cannot be attributed to a range of the remote text. | 1017 | unsupported: outline.unsupported.clone(), |
| 443 | return None; | 1018 | }, |
| 444 | } | 1019 | children, |
| 445 | let (b, o) = (base.text(), ours.text()); | 1020 | ); |
| 446 | let prefix = b | 1021 | } |
| 447 | .char_indices() | 1022 | |
| 448 | .zip(o.chars()) | 1023 | /// Indexes a flat paragraph list; returns the container's direct children. |
| 449 | .take_while(|((_, x), y)| x == y) | 1024 | fn paragraphs(&mut self, list: &[PageParagraph], container: ExGuid) -> Vec<ExGuid> { |
| 450 | .map(|((i, x), _)| i + x.len_utf8()) | 1025 | let mut children: BTreeMap<ExGuid, Vec<ExGuid>> = BTreeMap::new(); |
| 451 | .last() | 1026 | for paragraph in list { |
| 452 | .unwrap_or(0); | 1027 | children |
| 453 | let suffix = b[prefix..] | 1028 | .entry(paragraph.parent.unwrap_or(container)) |
| 454 | .chars() | 1029 | .or_default() |
| 455 | .rev() | 1030 | .push(paragraph.id); |
| 456 | .zip(o[prefix..].chars().rev()) | 1031 | } |
| 457 | .take_while(|(x, y)| x == y) | 1032 | for paragraph in list { |
| 458 | .map(|(x, _)| x.len_utf8()) | 1033 | let content = match &paragraph.content { |
| 459 | .sum::<usize>(); | 1034 | ParagraphContent::Text(text) => { |
| 460 | let start = base.utf16_offset(prefix).ok()?; | 1035 | self.add(text.id, paragraph.id, Own::Text(text.clone()), Vec::new()); |
| 461 | let end = base.utf16_offset(b.len() - suffix).ok()?; | 1036 | text.id |
| 462 | let inserted = ours.utf16_offset(o.len() - suffix).ok()?; | 1037 | } |
| 463 | let replacement = ours.slice(start..inserted).ok()?; | 1038 | ParagraphContent::Table(table) => { |
| 464 | let our_end = ours.utf16_offset(o.len()).ok()?; | 1039 | let rows = table.rows.iter().map(|row| row.id).collect(); |
| 465 | let base_end = base.utf16_offset(b.len()).ok()?; | 1040 | for row in &table.rows { |
| 466 | if (start > 0 && ours.slice(0..start).ok()? != base.slice(0..start).ok()?) | 1041 | let cells = row.cells.iter().map(|cell| cell.id).collect(); |
| 467 | || (end < base_end | 1042 | for cell in &row.cells { |
| 468 | && ours.slice(inserted..our_end).ok()? != base.slice(end..base_end).ok()?) | 1043 | let children = self.paragraphs(&cell.paragraphs, cell.id); |
| 469 | { | 1044 | self.add( |
| 470 | // Formatting changed outside the replaced range; the remote text would hide it. | 1045 | cell.id, |
| 471 | return None; | 1046 | row.id, |
| 472 | } | 1047 | Own::Cell { |
| 473 | let mapped = crate::rebase::rebase(b, theirs.text(), start..end)?; | 1048 | layout: cell.layout.clone(), |
| 474 | // Text they reformatted is not ours to replace, nor to move as a split would. | 1049 | indents: cell.indents.clone(), |
| 475 | if start < end && theirs.slice(mapped.clone()).ok()? != base.slice(start..end).ok()? { | 1050 | shading: cell.shading, |
| 476 | return None; | 1051 | unsupported: cell.unsupported.clone(), |
| 477 | } | 1052 | }, |
| 478 | let mut merged = theirs.clone(); | 1053 | children, |
| 479 | merged | 1054 | ); |
| 480 | .apply(Edit { | 1055 | } |
| 481 | range: mapped, | 1056 | self.add(row.id, table.id, Own::Row, cells); |
| 482 | replacement, | 1057 | } |
| 483 | }) | 1058 | self.add( |
| 484 | .ok()?; | 1059 | table.id, |
| 485 | Some(merged) | 1060 | paragraph.id, |
| 1061 | Own::Table { | ||
| 1062 | columns: table.columns.clone(), | ||
| 1063 | borders: table.borders, | ||
| 1064 | layout: table.layout.clone(), | ||
| 1065 | tags: table.tags.clone(), | ||
| 1066 | }, | ||
| 1067 | rows, | ||
| 1068 | ); | ||
| 1069 | table.id | ||
| 1070 | } | ||
| 1071 | ParagraphContent::Image(image) => { | ||
| 1072 | self.add( | ||
| 1073 | image.id, | ||
| 1074 | paragraph.id, | ||
| 1075 | Own::Image(image.clone()), | ||
| 1076 | Vec::new(), | ||
| 1077 | ); | ||
| 1078 | image.id | ||
| 1079 | } | ||
| 1080 | ParagraphContent::Attachment(attachment) => { | ||
| 1081 | self.add( | ||
| 1082 | attachment.id, | ||
| 1083 | paragraph.id, | ||
| 1084 | Own::Attachment(attachment.clone()), | ||
| 1085 | Vec::new(), | ||
| 1086 | ); | ||
| 1087 | attachment.id | ||
| 1088 | } | ||
| 1089 | ParagraphContent::Ink(ink) => { | ||
| 1090 | self.add(ink.id, paragraph.id, Own::Ink(ink.clone()), Vec::new()); | ||
| 1091 | ink.id | ||
| 1092 | } | ||
| 1093 | ParagraphContent::Unsupported(object) => { | ||
| 1094 | self.add( | ||
| 1095 | object.id, | ||
| 1096 | paragraph.id, | ||
| 1097 | Own::Unsupported(object.clone()), | ||
| 1098 | Vec::new(), | ||
| 1099 | ); | ||
| 1100 | object.id | ||
| 1101 | } | ||
| 1102 | }; | ||
| 1103 | self.add( | ||
| 1104 | paragraph.id, | ||
| 1105 | paragraph.parent.unwrap_or(container), | ||
| 1106 | Own::Paragraph { | ||
| 1107 | level: paragraph.level, | ||
| 1108 | style: paragraph.style, | ||
| 1109 | format: paragraph.format.clone(), | ||
| 1110 | lists: paragraph.lists.clone(), | ||
| 1111 | tags: paragraph.tags.clone(), | ||
| 1112 | media: paragraph.media.clone(), | ||
| 1113 | collapsed: paragraph.collapsed, | ||
| 1114 | content, | ||
| 1115 | }, | ||
| 1116 | children.remove(&paragraph.id).unwrap_or_default(), | ||
| 1117 | ); | ||
| 1118 | } | ||
| 1119 | children.remove(&container).unwrap_or_default() | ||
| 1120 | } | ||
| 486 | } | 1121 | } |
| 487 | 1122 | ||
| 488 | #[cfg(test)] | 1123 | #[cfg(test)] |
| 489 | mod tests { | 1124 | mod tests { |
| 490 | use super::*; | 1125 | use super::*; |
| 491 | use onestore::page::Paragraph; | ||
| 492 | 1126 | ||
| 493 | fn text(s: &str) -> Paragraph { | 1127 | fn text(s: &str) -> Paragraph { |
| 494 | Paragraph::new(s.into(), Default::default()) | 1128 | Paragraph::new(s.into(), Format::default()) |
| 495 | } | 1129 | } |
| 496 | 1130 | ||
| 497 | #[test] | 1131 | #[test] |
| 498 | fn same_paragraph_edits_merge_when_their_ranges_do_not_overlap() { | 1132 | fn ranges_clear_of_the_remote_changes_shift_past_them() { |
| 499 | let base = text("one two three"); | 1133 | let region = |start, end, delta| Region { start, end, delta }; |
| 500 | let ours = text("one two three four"); | 1134 | let changes = Changes::between(&text("one two three"), &text("one 2 three")); |
| 501 | assert_eq!( | 1135 | assert_eq!(changes.0, [region(4, 7, -2)]); |
| 502 | merge_text(&base, &ours, &text("zero one two three")) | 1136 | assert_eq!(changes.map(&(0..3)), Some(0..3)); |
| 503 | .unwrap() | 1137 | assert_eq!(changes.map(&(0..4)), Some(0..4)); |
| 504 | .text(), | 1138 | assert_eq!(changes.map(&(8..13)), Some(6..11)); |
| 505 | "zero one two three four" | 1139 | assert_eq!(changes.map(&(7..8)), Some(5..6)); |
| 506 | ); | 1140 | assert_eq!(changes.map(&(13..13)), Some(11..11)); |
| 507 | assert_eq!( | 1141 | assert_eq!(changes.map(&(4..4)), None); |
| 508 | merge_text(&base, &ours, &text("one 2 three")) | 1142 | assert_eq!(changes.map(&(7..7)), None); |
| 509 | .unwrap() | 1143 | assert_eq!(changes.map(&(3..5)), None); |
| 510 | .text(), | 1144 | let mut moved = changes.clone(); |
| 511 | "one 2 three four" | 1145 | moved.replaced(&(0..0), 5); |
| 512 | ); | 1146 | assert_eq!(moved.0, [region(9, 12, -2)]); |
| 513 | assert_eq!( | 1147 | let both = Changes::between(&text("ab🦀cd"), &text("Xab🦀cYd")); |
| 514 | merge_text(&base, &text("one two THREE"), &text("one two 3")), | 1148 | assert_eq!(both.0, [region(0, 0, 1), region(5, 5, 1)]); |
| 515 | None | 1149 | assert_eq!(both.map(&(2..4)), Some(3..5)); |
| 516 | ); | 1150 | assert_eq!(both.map(&(1..1)), Some(2..2)); |
| 517 | assert_eq!( | 1151 | assert_eq!(both.map(&(0..0)), None); |
| 518 | merge_text(&base, &text("one two three"), &text("x")) | 1152 | assert_eq!(both.map(&(3..6)), None); |
| 519 | .unwrap() | 1153 | let bold = Format { |
| 520 | .text(), | ||
| 521 | "x" | ||
| 522 | ); | ||
| 523 | assert_eq!( | ||
| 524 | merge_text(&base, &text("y"), &text("one two three")) | ||
| 525 | .unwrap() | ||
| 526 | .text(), | ||
| 527 | "y" | ||
| 528 | ); | ||
| 529 | let bold = onestore::document::Format { | ||
| 530 | bold: Some(true), | 1154 | bold: Some(true), |
| 531 | ..Default::default() | 1155 | ..Default::default() |
| 532 | }; | 1156 | }; |
| 533 | let remote = Paragraph::new("one two three".into(), bold); | 1157 | let formatted = Changes::between( |
| 534 | let merged = merge_text(&base, &text("one two three four"), &remote).unwrap(); | 1158 | &text("abc"), |
| 535 | assert_eq!(merged.text(), "one two three four"); | 1159 | &Paragraph::from_runs([ |
| 536 | assert_eq!(merged.format_at(0).unwrap(), remote.format_at(0).unwrap()); | 1160 | ("a".to_owned(), Format::default()), |
| 537 | assert_eq!( | 1161 | ("b".to_owned(), bold), |
| 538 | merge_text(&base, &remote, &text("one two three four")), | 1162 | ("c".to_owned(), Format::default()), |
| 539 | None | 1163 | ]), |
| 540 | ); | ||
| 541 | assert_eq!( | ||
| 542 | merge_text(&base, &text("one two"), &text("X one two three")) | ||
| 543 | .unwrap() | ||
| 544 | .text(), | ||
| 545 | "X one two" | ||
| 546 | ); | 1164 | ); |
| 1165 | assert_eq!(formatted.0, [region(1, 2, 0)]); | ||
| 1166 | let emoji = Changes::between(&text("🦀a"), &text("🦀ab")); | ||
| 1167 | assert_eq!(emoji.0, [region(3, 3, 1)]); | ||
| 1168 | let mut cut = Changes::between(&text("abcdef"), &text("aXbcdeYf")); | ||
| 1169 | assert_eq!(cut.map(&(3..4)), Some(4..5)); | ||
| 1170 | let right = cut.split(3); | ||
| 547 | assert_eq!( | 1171 | assert_eq!( |
| 548 | merge_text(&base, &text("two three"), &text("one two three!")) | 1172 | (cut.0, right.0), |
| 549 | .unwrap() | 1173 | (vec![region(1, 1, 1)], vec![region(2, 2, 1)]) |
| 550 | .text(), | ||
| 551 | "two three!" | ||
| 552 | ); | 1174 | ); |
| 1175 | let mut typed = Changes::between(&text("ab🦀cd"), &text("abX🦀cd")); | ||
| 1176 | assert_eq!(typed.map(&(2..3)), Some(3..4)); | ||
| 1177 | assert!(typed.split(2).0.is_empty()); | ||
| 1178 | assert_eq!(typed.0, [region(2, 2, 1)]); | ||
| 553 | } | 1179 | } |
| 554 | } | 1180 | } |
crates/notebook/src/migrate.rs created+303| ... | @@ -0,0 +1,303 @@ | ||
| 1 | //! Converts a schema-14 cache, whose queue holds whole-page edits over a working image, | ||
| 2 | //! to this schema's op queue. The cache is exported first; each queued page is lowered to | ||
| 3 | //! ops against the page the conversion has so far, and every converted page must equal | ||
| 4 | //! the page in the old working image, or nothing changes. | ||
| 5 | |||
| 6 | use crate::{Result, base, queue, schema}; | ||
| 7 | use onestore::{ | ||
| 8 | Arena, ExGuid, PageCreation, PageEdit, RevisionIndex, Section, Store, | ||
| 9 | document::Document, | ||
| 10 | op::{Edit, Op, SectionOp}, | ||
| 11 | page::Page, | ||
| 12 | }; | ||
| 13 | use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; | ||
| 14 | use std::{collections::BTreeMap, io, path::Path}; | ||
| 15 | |||
| 16 | pub(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)] | ||
| 21 | enum 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)] | ||
| 30 | struct 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)] | ||
| 39 | struct PageEdits { | ||
| 40 | edits: Vec<PageEdit>, | ||
| 41 | #[allow(dead_code)] | ||
| 42 | observed: Vec<(ExGuid, u32)>, | ||
| 43 | } | ||
| 44 | |||
| 45 | fn 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. | ||
| 51 | fn 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 | |||
| 84 | struct 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 | |||
| 93 | pub(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 | |||
| 110 | fn 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. | ||
| 285 | fn 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 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{PageEdit, PagePosition}; | ||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | use std::collections::BTreeMap; | ||
| 5 | |||
| 6 | type 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)] | ||
| 11 | pub struct PageEdits { | ||
| 12 | pub edits: Vec<PageEdit>, | ||
| 13 | observed: PageOrder, | ||
| 14 | } | ||
| 15 | |||
| 16 | fn 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 | |||
| 34 | fn 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 | |||
| 51 | impl 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 | |||
| 59 | impl 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 | |||
| 4 | use crate::{PendingEdit, Result, signed, unsigned}; | ||
| 5 | use onestore::{ | ||
| 6 | op::{Edit, Op, PageOp, SectionOp, TableEdit}, | ||
| 7 | page::{PageObject, PageParagraph, ParagraphContent, TableCell}, | ||
| 8 | }; | ||
| 9 | use rusqlite::{Connection, OptionalExtension, params}; | ||
| 10 | use sha2::{Digest, Sha256}; | ||
| 11 | use std::{io, sync::Arc}; | ||
| 12 | |||
| 13 | type Payload = Option<Arc<[u8]>>; | ||
| 14 | |||
| 15 | fn 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 | |||
| 35 | fn cells(list: &mut [TableCell], visit: &mut impl FnMut(&mut Payload)) { | ||
| 36 | for cell in list { | ||
| 37 | paragraphs(&mut cell.paragraphs, visit); | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | fn 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. | ||
| 55 | fn 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 | |||
| 87 | fn 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. | ||
| 93 | pub(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 | |||
| 108 | fn hex(digest: &[u8]) -> String { | ||
| 109 | digest.iter().map(|byte| format!("{byte:02x}")).collect() | ||
| 110 | } | ||
| 111 | |||
| 112 | /// Replaces a stored edit's ops. | ||
| 113 | pub(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. | ||
| 123 | fn 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 | |||
| 155 | fn 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. | ||
| 200 | pub(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. | ||
| 217 | pub(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. | ||
| 227 | pub(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)] | ||
| 250 | mod 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 @@ | ||
| 1 | use std::ops::Range; | ||
| 2 | |||
| 3 | const INFINITY: u32 = 1 << 30; | ||
| 4 | const MAX_CELLS: usize = 4_000_000; | ||
| 5 | |||
| 6 | struct Matrix { | ||
| 7 | band: usize, | ||
| 8 | values: Vec<u32>, | ||
| 9 | } | ||
| 10 | |||
| 11 | impl 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. | ||
| 55 | pub(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)] | ||
| 151 | mod tests { | ||
| 152 | use super::*; | ||
| 153 | |||
| 154 | fn optimal_paths(a: &[char], b: &[char]) -> Vec<Vec<(usize, usize)>> { | ||
| 155 | fn visit( | ||
| 156 | a: &[char], | ||
| 157 | b: &[char], | ||
| 158 | path: &mut Vec<(usize, usize)>, | ||
| 159 | cost: usize, | ||
| 160 | best: &mut usize, | ||
| 161 | found: &mut Vec<Vec<(usize, usize)>>, | ||
| 162 | ) { | ||
| 163 | if cost > *best { | ||
| 164 | return; | ||
| 165 | } | ||
| 166 | let (i, j) = *path.last().unwrap(); | ||
| 167 | if (i, j) == (a.len(), b.len()) { | ||
| 168 | if cost < *best { | ||
| 169 | found.clear(); | ||
| 170 | *best = cost; | ||
| 171 | } | ||
| 172 | found.push(path.clone()); | ||
| 173 | return; | ||
| 174 | } | ||
| 175 | for (next_i, next_j, charge) in [(i + 1, j + 1, 0), (i + 1, j, 1), (i, j + 1, 1)] { | ||
| 176 | if next_i > a.len() || next_j > b.len() || (charge == 0 && a[i] != b[j]) { | ||
| 177 | continue; | ||
| 178 | } | ||
| 179 | path.push((next_i, next_j)); | ||
| 180 | visit(a, b, path, cost + charge, best, found); | ||
| 181 | path.pop(); | ||
| 182 | } | ||
| 183 | } | ||
| 184 | let mut found = Vec::new(); | ||
| 185 | let mut best = usize::MAX; | ||
| 186 | visit(a, b, &mut vec![(0, 0)], 0, &mut best, &mut found); | ||
| 187 | found | ||
| 188 | } | ||
| 189 | |||
| 190 | #[test] | ||
| 191 | fn bounded_alignment_agrees_with_exhaustive_paths_for_every_small_unicode_edit() { | ||
| 192 | let mut words = vec![String::new()]; | ||
| 193 | for length in 1..=4 { | ||
| 194 | for bits in 0..1 << length { | ||
| 195 | words.push( | ||
| 196 | (0..length) | ||
| 197 | .map(|bit| if bits & (1 << bit) == 0 { 'a' } else { '🦀' }) | ||
| 198 | .collect(), | ||
| 199 | ); | ||
| 200 | } | ||
| 201 | } | ||
| 202 | let mut cases = 0; | ||
| 203 | for before in &words { | ||
| 204 | let a: Vec<_> = before.chars().collect(); | ||
| 205 | for after in &words { | ||
| 206 | let b: Vec<_> = after.chars().collect(); | ||
| 207 | let paths = optimal_paths(&a, &b); | ||
| 208 | for start in 0..=a.len() { | ||
| 209 | for end in start..=a.len() { | ||
| 210 | let mut expected = None; | ||
| 211 | let mut valid = true; | ||
| 212 | for path in &paths { | ||
| 213 | let mapping: Vec<_> = (0..a.len()) | ||
| 214 | .map(|i| { | ||
| 215 | path.windows(2).find_map(|edge| { | ||
| 216 | (edge[0].0 == i && edge[1] == (i + 1, edge[0].1 + 1)) | ||
| 217 | .then_some(edge[0].1) | ||
| 218 | }) | ||
| 219 | }) | ||
| 220 | .collect(); | ||
| 221 | let candidate = if start == end { | ||
| 222 | let vertices: Vec<_> = path | ||
| 223 | .iter() | ||
| 224 | .filter(|(i, _)| *i == start) | ||
| 225 | .map(|(_, j)| *j) | ||
| 226 | .collect(); | ||
| 227 | if (start > 0 && mapping[start - 1].is_none()) | ||
| 228 | || (start < a.len() && mapping[start].is_none()) | ||
| 229 | || vertices.len() != 1 | ||
| 230 | { | ||
| 231 | None | ||
| 232 | } else { | ||
| 233 | Some(vertices[0]..vertices[0]) | ||
| 234 | } | ||
| 235 | } else if let Some(first) = mapping[start] { | ||
| 236 | (start..end) | ||
| 237 | .all(|i| mapping[i] == Some(first + i - start)) | ||
| 238 | .then_some(first..first + end - start) | ||
| 239 | } else { | ||
| 240 | None | ||
| 241 | }; | ||
| 242 | let Some(candidate) = candidate else { | ||
| 243 | valid = false; | ||
| 244 | break; | ||
| 245 | }; | ||
| 246 | if expected.as_ref().is_some_and(|old| *old != candidate) { | ||
| 247 | valid = false; | ||
| 248 | break; | ||
| 249 | } | ||
| 250 | expected = Some(candidate); | ||
| 251 | } | ||
| 252 | let expected = if valid { | ||
| 253 | expected.map(|range| { | ||
| 254 | b[..range.start].iter().map(|c| c.len_utf16() as u32).sum() | ||
| 255 | ..b[..range.end].iter().map(|c| c.len_utf16() as u32).sum() | ||
| 256 | }) | ||
| 257 | } else { | ||
| 258 | None | ||
| 259 | }; | ||
| 260 | let range = a[..start].iter().map(|c| c.len_utf16() as u32).sum() | ||
| 261 | ..a[..end].iter().map(|c| c.len_utf16() as u32).sum(); | ||
| 262 | assert_eq!( | ||
| 263 | rebase(before, after, range), | ||
| 264 | expected, | ||
| 265 | "{before:?} -> {after:?}, characters {start}..{end}" | ||
| 266 | ); | ||
| 267 | cases += 1; | ||
| 268 | } | ||
| 269 | } | ||
| 270 | } | ||
| 271 | } | ||
| 272 | assert_eq!(cases, 10881); | ||
| 273 | } | ||
| 274 | |||
| 275 | #[test] | ||
| 276 | fn maps_disjoint_unicode_edits_and_rejects_ambiguous_or_overlapping_changes() { | ||
| 277 | assert_eq!(rebase("ab🦀cd", "Xab🦀cYd", 2..4), Some(3..5)); | ||
| 278 | assert_eq!(rebase("abc", "XabcY", 1..2), Some(2..3)); | ||
| 279 | assert_eq!(rebase("abc", "XabcY", 1..1), Some(2..2)); | ||
| 280 | assert_eq!(rebase("abc", "abcX", 3..3), None); | ||
| 281 | assert_eq!(rebase("abc", "ac", 1..2), None); | ||
| 282 | assert_eq!(rebase("abc", "", 1..1), None); | ||
| 283 | assert_eq!(rebase("aaaa", "aaaaa", 1..2), None); | ||
| 284 | assert_eq!(rebase("ab🦀cd", "ab🦀cd", 3..4), None); | ||
| 285 | assert_eq!(rebase("abc", "abc", 4..4), None); | ||
| 286 | } | ||
| 287 | |||
| 288 | #[test] | ||
| 289 | fn long_paragraphs_with_small_remote_changes_use_a_narrow_band() { | ||
| 290 | let text = format!("{}🦀{}", "a".repeat(8192), "b".repeat(8192)); | ||
| 291 | assert_eq!( | ||
| 292 | rebase(&text, &format!("X{text}Y"), 8192..8194), | ||
| 293 | Some(8193..8195) | ||
| 294 | ); | ||
| 295 | assert_eq!(rebase(&"a".repeat(8192), &"b".repeat(8192), 1..2), None); | ||
| 296 | } | ||
| 297 | } | ||
crates/notebook/src/recovery.rs+90-78| ... | @@ -11,7 +11,9 @@ pub struct RecoverySummary { | ... | @@ -11,7 +11,9 @@ pub struct RecoverySummary { |
| 11 | pub conflicts: u64, | 11 | pub conflicts: u64, |
| 12 | pub uncertain_edits: u64, | 12 | pub uncertain_edits: u64, |
| 13 | pub published_receipts: u64, | 13 | pub published_receipts: u64, |
| 14 | pub working_bytes: u64, | 14 | /// Bytes of the image the queued edits apply to. |
| 15 | pub base_bytes: u64, | ||
| 16 | /// Bytes of the observed remote image, while it is not the base. | ||
| 15 | pub remote_bytes: u64, | 17 | pub remote_bytes: u64, |
| 16 | pub cached_assets: u64, | 18 | pub cached_assets: u64, |
| 17 | pub cached_asset_bytes: u64, | 19 | pub cached_asset_bytes: u64, |
| ... | @@ -36,33 +38,48 @@ impl Recovery { | ... | @@ -36,33 +38,48 @@ impl Recovery { |
| 36 | io::Error::new(io::ErrorKind::InvalidData, "Not a recovery archive").into(), | 38 | io::Error::new(io::ErrorKind::InvalidData, "Not a recovery archive").into(), |
| 37 | ); | 39 | ); |
| 38 | } | 40 | } |
| 39 | if version != SCHEMA_VERSION { | 41 | if version != schema::VERSION { |
| 40 | return Err(io::Error::new( | 42 | return Err(io::Error::new( |
| 41 | io::ErrorKind::InvalidData, | 43 | io::ErrorKind::InvalidData, |
| 42 | format!( | 44 | format!( |
| 43 | "Archive schema version {version} is not the supported version {SCHEMA_VERSION}" | 45 | "Archive schema version {version} is not the supported version {}", |
| 46 | schema::VERSION | ||
| 44 | ), | 47 | ), |
| 45 | ) | 48 | ) |
| 46 | .into()); | 49 | .into()); |
| 47 | } | 50 | } |
| 48 | validate_images(&connection)?; | 51 | let recovery = Self { connection }; |
| 49 | for edit in pending(&connection)? { | 52 | recovery.snapshot()?; |
| 50 | sync::status(&connection, edit.id)?; | 53 | for edit in pending(&recovery.connection)? { |
| 54 | sync::status(&recovery.connection, edit.id)?; | ||
| 51 | } | 55 | } |
| 52 | receipts(&connection)?; | 56 | receipts(&recovery.connection)?; |
| 53 | Ok(Self { connection }) | 57 | Ok(recovery) |
| 54 | } | 58 | } |
| 55 | 59 | ||
| 56 | pub fn summary(&self) -> Result<RecoverySummary> { | 60 | pub fn summary(&self) -> Result<RecoverySummary> { |
| 57 | summary(&self.connection) | 61 | summary(&self.connection) |
| 58 | } | 62 | } |
| 59 | 63 | ||
| 64 | /// The image the archived queue leaves, as `Replica::snapshot` gives it. | ||
| 60 | pub fn snapshot(&self) -> Result<Vec<u8>> { | 65 | pub fn snapshot(&self) -> Result<Vec<u8>> { |
| 61 | crate::images::working(&self.connection) | 66 | working::image(&self.connection) |
| 62 | } | 67 | } |
| 63 | 68 | ||
| 69 | fn base(&self) -> Result<Vec<u8>> { | ||
| 70 | Ok( | ||
| 71 | base::read(&self.connection, base::Image::Base)?.ok_or_else(|| { | ||
| 72 | io::Error::new(io::ErrorKind::InvalidData, "The archive has no base image") | ||
| 73 | })?, | ||
| 74 | ) | ||
| 75 | } | ||
| 76 | |||
| 77 | /// The last observed remote image. | ||
| 64 | pub fn remote_snapshot(&self) -> Result<Vec<u8>> { | 78 | pub fn remote_snapshot(&self) -> Result<Vec<u8>> { |
| 65 | crate::images::base(&self.connection) | 79 | match base::read(&self.connection, base::Image::Remote)? { |
| 80 | Some(image) => Ok(image), | ||
| 81 | None => self.base(), | ||
| 82 | } | ||
| 66 | } | 83 | } |
| 67 | 84 | ||
| 68 | pub fn pending(&self) -> Result<Vec<PendingEdit>> { | 85 | pub fn pending(&self) -> Result<Vec<PendingEdit>> { |
| ... | @@ -86,83 +103,88 @@ impl Recovery { | ... | @@ -86,83 +103,88 @@ impl Recovery { |
| 86 | impl Replica { | 103 | impl Replica { |
| 87 | /// Summarizes durable state without exposing notebook content. | 104 | /// Summarizes durable state without exposing notebook content. |
| 88 | pub fn recovery_summary(&self) -> Result<RecoverySummary> { | 105 | pub fn recovery_summary(&self) -> Result<RecoverySummary> { |
| 89 | let connection = self | 106 | summary(&*self.lock()?) |
| 90 | .connection | ||
| 91 | .lock() | ||
| 92 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 93 | summary(&connection) | ||
| 94 | } | 107 | } |
| 95 | 108 | ||
| 96 | /// Exports a consistent archive to a new local path without changing the live queue. | 109 | /// Exports a consistent archive to a new local path without changing the live queue. |
| 97 | /// Archives contain notebook content and cannot be opened as writable replicas. | 110 | /// Archives contain notebook content and cannot be opened as writable replicas. |
| 98 | /// An error after publication can leave a complete archive at the destination. | 111 | /// An error after publication can leave a complete archive at the destination. |
| 99 | pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> { | 112 | pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> { |
| 100 | let path = path.as_ref(); | 113 | export(&*self.lock()?, path.as_ref(), false) |
| 101 | if path.file_name().is_none() { | 114 | } |
| 115 | } | ||
| 116 | |||
| 117 | /// Copies the cache to `path` as a recovery archive; `replace` overwrites an existing file. | ||
| 118 | pub(crate) fn export(source: &Connection, path: &Path, replace: bool) -> Result<()> { | ||
| 119 | if path.file_name().is_none() { | ||
| 120 | return Err(io::Error::new( | ||
| 121 | io::ErrorKind::InvalidInput, | ||
| 122 | "Provide a new recovery archive filename", | ||
| 123 | ) | ||
| 124 | .into()); | ||
| 125 | } | ||
| 126 | let parent = path | ||
| 127 | .parent() | ||
| 128 | .filter(|parent| !parent.as_os_str().is_empty()) | ||
| 129 | .unwrap_or_else(|| Path::new(".")); | ||
| 130 | let temporary = tempfile::Builder::new() | ||
| 131 | .prefix(".onestore-recovery-") | ||
| 132 | .tempfile_in(parent)?; | ||
| 133 | let mut destination = cache_connection(temporary.path())?; | ||
| 134 | { | ||
| 135 | let backup = Backup::new(source, &mut destination)?; | ||
| 136 | if backup.step(-1)? != StepResult::Done { | ||
| 102 | return Err(io::Error::new( | 137 | return Err(io::Error::new( |
| 103 | io::ErrorKind::InvalidInput, | 138 | io::ErrorKind::WouldBlock, |
| 104 | "Provide a new recovery archive filename", | 139 | "Recovery export could not acquire the database snapshot", |
| 105 | ) | 140 | ) |
| 106 | .into()); | 141 | .into()); |
| 107 | } | 142 | } |
| 108 | let parent = path | 143 | } |
| 109 | .parent() | 144 | // One self-contained file: the copied header says WAL, as the cache does. |
| 110 | .filter(|parent| !parent.as_os_str().is_empty()) | 145 | destination.pragma_update(None, "journal_mode", "DELETE")?; |
| 111 | .unwrap_or_else(|| Path::new(".")); | 146 | destination.pragma_update(None, "application_id", RECOVERY_ID)?; |
| 112 | let temporary = tempfile::Builder::new() | 147 | destination.close().map_err(|(_, error)| error)?; |
| 113 | .prefix(".onestore-recovery-") | 148 | temporary.as_file().sync_all()?; |
| 114 | .tempfile_in(parent)?; | 149 | if replace { |
| 115 | let mut destination = cache_connection(temporary.path())?; | 150 | temporary.persist(path).map_err(|error| error.error)?; |
| 116 | { | 151 | } else { |
| 117 | let source = self | ||
| 118 | .connection | ||
| 119 | .lock() | ||
| 120 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 121 | let backup = Backup::new(&source, &mut destination)?; | ||
| 122 | if backup.step(-1)? != StepResult::Done { | ||
| 123 | return Err(io::Error::new( | ||
| 124 | io::ErrorKind::WouldBlock, | ||
| 125 | "Recovery export could not acquire the database snapshot", | ||
| 126 | ) | ||
| 127 | .into()); | ||
| 128 | } | ||
| 129 | } | ||
| 130 | destination.pragma_update(None, "application_id", RECOVERY_ID)?; | ||
| 131 | destination.close().map_err(|(_, error)| error)?; | ||
| 132 | temporary.as_file().sync_all()?; | ||
| 133 | temporary | 152 | temporary |
| 134 | .persist_noclobber(path) | 153 | .persist_noclobber(path) |
| 135 | .map_err(|error| error.error)?; | 154 | .map_err(|error| error.error)?; |
| 136 | File::open(parent)?.sync_all()?; | ||
| 137 | Ok(()) | ||
| 138 | } | 155 | } |
| 156 | File::open(parent)?.sync_all()?; | ||
| 157 | Ok(()) | ||
| 139 | } | 158 | } |
| 140 | 159 | ||
| 141 | fn summary(connection: &Connection) -> Result<RecoverySummary> { | 160 | fn summary(connection: &Connection) -> Result<RecoverySummary> { |
| 142 | let (cached_assets, cached_asset_bytes) = connection.query_row( | 161 | let (cached_assets, cached_asset_bytes): (i64, i64) = connection.query_row( |
| 143 | "SELECT count(*),coalesce(sum(length(data)),0) FROM assets", | 162 | "SELECT count(*),coalesce(sum(length(data)),0) FROM assets", |
| 144 | [], | 163 | [], |
| 145 | |row| Ok((unsigned(row, 0)?, unsigned(row, 1)?)), | 164 | |row| Ok((row.get(0)?, row.get(1)?)), |
| 146 | )?; | 165 | )?; |
| 147 | let working_bytes = crate::images::working_length(connection)?; | 166 | let length = |image| -> Result<u64> { |
| 148 | Ok(connection.query_row( | 167 | Ok(base::stamp(connection, image)?.map_or(0, |stamp| stamp.length)) |
| 149 | "SELECT (SELECT count(*) FROM edits), (SELECT count(*) FROM conflicts), | 168 | }; |
| 150 | (SELECT count(*) FROM attempt), (SELECT count(*) FROM receipts), | 169 | let (queued_edits, conflicts, uncertain_edits, published_receipts): (i64, i64, i64, i64) = |
| 151 | length(base) FROM replica WHERE id=1", | 170 | connection.query_row( |
| 152 | [], | 171 | "SELECT (SELECT count(*) FROM edits), |
| 153 | |row| { | 172 | (SELECT count(*) FROM edits JOIN batches ON batches.id=edits.batch WHERE conflict IS NOT NULL), |
| 154 | Ok(RecoverySummary { | 173 | (SELECT count(*) FROM edits JOIN batches ON batches.id=edits.batch WHERE attempted=1), |
| 155 | queued_edits: unsigned(row, 0)?, | 174 | (SELECT count(*) FROM receipts)", |
| 156 | conflicts: unsigned(row, 1)?, | 175 | [], |
| 157 | uncertain_edits: unsigned(row, 2)?, | 176 | |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), |
| 158 | published_receipts: unsigned(row, 3)?, | 177 | )?; |
| 159 | working_bytes, | 178 | Ok(RecoverySummary { |
| 160 | remote_bytes: unsigned(row, 4)?, | 179 | queued_edits: unsigned(queued_edits)?, |
| 161 | cached_assets, | 180 | conflicts: unsigned(conflicts)?, |
| 162 | cached_asset_bytes, | 181 | uncertain_edits: unsigned(uncertain_edits)?, |
| 163 | }) | 182 | published_receipts: unsigned(published_receipts)?, |
| 164 | }, | 183 | base_bytes: length(base::Image::Base)?, |
| 165 | )?) | 184 | remote_bytes: length(base::Image::Remote)?, |
| 185 | cached_assets: unsigned(cached_assets)?, | ||
| 186 | cached_asset_bytes: unsigned(cached_asset_bytes)?, | ||
| 187 | }) | ||
| 166 | } | 188 | } |
| 167 | 189 | ||
| 168 | fn receipts(connection: &Connection) -> Result<BTreeMap<u64, ExGuid>> { | 190 | fn receipts(connection: &Connection) -> Result<BTreeMap<u64, ExGuid>> { |
| ... | @@ -171,17 +193,7 @@ fn receipts(connection: &Connection) -> Result<BTreeMap<u64, ExGuid>> { | ... | @@ -171,17 +193,7 @@ fn receipts(connection: &Connection) -> Result<BTreeMap<u64, ExGuid>> { |
| 171 | let mut rows = statement.query([])?; | 193 | let mut rows = statement.query([])?; |
| 172 | let mut receipts = BTreeMap::new(); | 194 | let mut receipts = BTreeMap::new(); |
| 173 | while let Some(row) = rows.next()? { | 195 | while let Some(row) = rows.next()? { |
| 174 | receipts.insert(unsigned(row, 0)?, row.get::<_, String>(1)?.parse()?); | 196 | receipts.insert(unsigned(row.get(0)?)?, row.get::<_, String>(1)?.parse()?); |
| 175 | } | 197 | } |
| 176 | Ok(receipts) | 198 | Ok(receipts) |
| 177 | } | 199 | } |
| 178 | |||
| 179 | fn unsigned(row: &rusqlite::Row<'_>, column: usize) -> rusqlite::Result<u64> { | ||
| 180 | u64::try_from(row.get::<_, i64>(column)?).map_err(|error| { | ||
| 181 | rusqlite::Error::FromSqlConversionFailure( | ||
| 182 | column, | ||
| 183 | rusqlite::types::Type::Integer, | ||
| 184 | Box::new(error), | ||
| 185 | ) | ||
| 186 | }) | ||
| 187 | } |
crates/notebook/src/schema.rs+45-31| ... | @@ -1,39 +1,53 @@ | ... | @@ -1,39 +1,53 @@ |
| 1 | use super::*; | 1 | use super::*; |
| 2 | use rusqlite::Transaction; | 2 | use rusqlite::Transaction; |
| 3 | 3 | ||
| 4 | const CONFLICTS: &str = "CREATE TABLE conflicts ( | 4 | pub(crate) const VERSION: u32 = 15; |
| 5 | edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, | ||
| 6 | kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 3) | ||
| 7 | ) STRICT;"; | ||
| 8 | 5 | ||
| 9 | const ASSETS: &str = "CREATE TABLE assets ( | 6 | /// Tables a schema-14 cache shares with this one. |
| 10 | name TEXT PRIMARY KEY NOT NULL, | 7 | pub(crate) const KEPT: &str = " |
| 11 | data BLOB NOT NULL, | 8 | CREATE TABLE receipts ( |
| 12 | sha256 BLOB NOT NULL CHECK(length(sha256)=32) | 9 | edit_id INTEGER PRIMARY KEY CHECK(edit_id>0), |
| 13 | ) STRICT;"; | 10 | revision TEXT NOT NULL |
| 11 | ) STRICT; | ||
| 12 | CREATE TABLE archived ( | ||
| 13 | edit_id INTEGER PRIMARY KEY CHECK(edit_id>0), | ||
| 14 | archive TEXT NOT NULL | ||
| 15 | ) STRICT; | ||
| 16 | CREATE TABLE assets ( | ||
| 17 | name TEXT PRIMARY KEY NOT NULL, | ||
| 18 | data BLOB NOT NULL, | ||
| 19 | sha256 BLOB NOT NULL CHECK(length(sha256)=32) | ||
| 20 | ) STRICT;"; | ||
| 21 | |||
| 22 | /// The queue: the image its edits apply to, in chunks, and the edits in publication batches. | ||
| 23 | pub(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;"; | ||
| 14 | 48 | ||
| 15 | pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> { | 49 | pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> { |
| 16 | transaction.execute_batch( | 50 | transaction.execute_batch(QUEUE)?; |
| 17 | "CREATE TABLE edits ( | 51 | transaction.execute_batch(KEPT)?; |
| 18 | id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), | ||
| 19 | space TEXT NOT NULL, | ||
| 20 | operation TEXT NOT NULL | ||
| 21 | ) STRICT; | ||
| 22 | CREATE TABLE attempt ( | ||
| 23 | id INTEGER PRIMARY KEY CHECK(id=1), | ||
| 24 | edit_id INTEGER NOT NULL UNIQUE REFERENCES edits(id) ON DELETE CASCADE, | ||
| 25 | revisions TEXT NOT NULL | ||
| 26 | ) STRICT; | ||
| 27 | CREATE TABLE receipts ( | ||
| 28 | edit_id INTEGER PRIMARY KEY CHECK(edit_id>0), | ||
| 29 | revision TEXT NOT NULL | ||
| 30 | ) STRICT; | ||
| 31 | CREATE TABLE archived ( | ||
| 32 | edit_id INTEGER PRIMARY KEY CHECK(edit_id>0), | ||
| 33 | archive TEXT NOT NULL | ||
| 34 | ) STRICT;", | ||
| 35 | )?; | ||
| 36 | transaction.execute_batch(CONFLICTS)?; | ||
| 37 | transaction.execute_batch(ASSETS)?; | ||
| 38 | Ok(()) | 52 | Ok(()) |
| 39 | } | 53 | } |
crates/notebook/src/session.rs+246-299| ... | @@ -1,15 +1,18 @@ | ... | @@ -1,15 +1,18 @@ |
| 1 | //! The application's view of a notebook: sections opened through a local replica that | 1 | //! 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. |
| 3 | 3 | ||
| 4 | use crate::{ | 4 | use crate::{ |
| 5 | ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover, | 5 | Conflict, ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, |
| 6 | SyncWorker, discover, | ||
| 6 | }; | 7 | }; |
| 7 | use onestore::{ | 8 | use onestore::{ |
| 8 | CommitError, ExGuid, PageCreation, PreparedEdit, RevisionIndex, Stamp, Store, Transaction, | 9 | CommitError, ExGuid, PageCreation, PreparedEdit, RevisionIndex, Stamp, Store, Transaction, |
| 9 | document::Document, page::Page, | 10 | document::Document, |
| 11 | op::{Edit, Op, SectionOp}, | ||
| 12 | page::Page, | ||
| 10 | }; | 13 | }; |
| 11 | use std::{ | 14 | use std::{ |
| 12 | collections::{BTreeMap, VecDeque}, | 15 | collections::BTreeMap, |
| 13 | io, | 16 | io, |
| 14 | io::Write, | 17 | io::Write, |
| 15 | path::{Path, PathBuf}, | 18 | path::{Path, PathBuf}, |
| ... | @@ -18,7 +21,6 @@ use std::{ | ... | @@ -18,7 +21,6 @@ use std::{ |
| 18 | atomic::{AtomicUsize, Ordering}, | 21 | atomic::{AtomicUsize, Ordering}, |
| 19 | mpsc::{self, Receiver, Sender}, | 22 | mpsc::{self, Receiver, Sender}, |
| 20 | }, | 23 | }, |
| 21 | thread::{self, JoinHandle}, | ||
| 22 | time::Duration, | 24 | time::Duration, |
| 23 | }; | 25 | }; |
| 24 | 26 | ||
| ... | @@ -723,10 +725,15 @@ fn catalog_path(folder: &str, name: &str) -> String { | ... | @@ -723,10 +725,15 @@ fn catalog_path(folder: &str, name: &str) -> String { |
| 723 | } | 725 | } |
| 724 | } | 726 | } |
| 725 | 727 | ||
| 726 | /// What happened on the synchronization thread since the last poll. | 728 | /// What happened to the section since the last poll. |
| 727 | #[derive(Debug)] | 729 | #[derive(Debug)] |
| 728 | pub enum Event { | 730 | pub enum Event { |
| 729 | /// The working image was refreshed from the section file with no local edit pending. | 731 | /// A remote change reached these pages; reload them where they are open. |
| 732 | Changed(Vec<ExGuid>), | ||
| 733 | /// The section refused an edit `apply` handed it; the pages it names are as they were | ||
| 734 | /// before it, so an editor showing them should reload them. | ||
| 735 | Rejected { spaces: Vec<ExGuid>, error: String }, | ||
| 736 | /// Old session API, until the application reloads on `Changed`: pages changed remotely. | ||
| 730 | Refreshed, | 737 | Refreshed, |
| 731 | /// A publication attempt finished with this durable state. | 738 | /// A publication attempt finished with this durable state. |
| 732 | Attempt { id: u64, status: EditStatus }, | 739 | Attempt { id: u64, status: EditStatus }, |
| ... | @@ -736,7 +743,7 @@ pub enum Event { | ... | @@ -736,7 +743,7 @@ pub enum Event { |
| 736 | Failed(String), | 743 | Failed(String), |
| 737 | } | 744 | } |
| 738 | 745 | ||
| 739 | /// The outcome of saving an edited page. | 746 | /// The outcome of saving an edited page (old session API). |
| 740 | #[derive(Debug, PartialEq, Eq)] | 747 | #[derive(Debug, PartialEq, Eq)] |
| 741 | pub enum Save { | 748 | pub enum Save { |
| 742 | /// The model equals the stored page. | 749 | /// The model equals the stored page. |
| ... | @@ -755,31 +762,27 @@ pub struct QueuedEdit { | ... | @@ -755,31 +762,27 @@ pub struct QueuedEdit { |
| 755 | pub status: EditStatus, | 762 | pub status: EditStatus, |
| 756 | } | 763 | } |
| 757 | 764 | ||
| 758 | /// A section file with its replica, background saves and background publication. | 765 | type 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. | ||
| 759 | pub struct Section { | 769 | pub struct Section { |
| 760 | file: PathBuf, | 770 | file: PathBuf, |
| 761 | replica: Arc<Replica>, | 771 | replica: Arc<Replica>, |
| 762 | worker: Option<SyncWorker>, | 772 | worker: Option<SyncWorker>, |
| 763 | events: Receiver<Event>, | 773 | events: Mutex<Receiver<Event>>, |
| 764 | saves: Option<Sender<PageSave>>, | 774 | sender: Sender<Event>, |
| 765 | saved: Receiver<(ExGuid, std::result::Result<Save, String>)>, | 775 | notify: Notify, |
| 776 | saved: Mutex<Receiver<(ExGuid, std::result::Result<Save, String>)>>, | ||
| 777 | saves: Sender<(ExGuid, std::result::Result<Save, String>)>, | ||
| 766 | /// Saves queued and not yet reported. | 778 | /// Saves queued and not yet reported. |
| 767 | unsaved: Arc<AtomicUsize>, | 779 | unsaved: Arc<AtomicUsize>, |
| 768 | saver: Option<JoinHandle<()>>, | ||
| 769 | } | ||
| 770 | |||
| 771 | /// An edited page `Section::queue_save` hands to the save thread. | ||
| 772 | struct PageSave { | ||
| 773 | space: ExGuid, | ||
| 774 | before: Page, | ||
| 775 | after: Page, | ||
| 776 | author: String, | ||
| 777 | } | 780 | } |
| 778 | 781 | ||
| 779 | impl Section { | 782 | impl Section { |
| 780 | /// Opens the section file through a replica in `cache`, creating the replica from the | 783 | /// 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 | 784 | /// file on first use and converting an older one. `notify` runs on a background thread |
| 782 | /// available. | 785 | /// whenever an event is available. |
| 783 | pub fn open( | 786 | pub fn open( |
| 784 | file: impl AsRef<Path>, | 787 | file: impl AsRef<Path>, |
| 785 | cache: impl AsRef<Path>, | 788 | cache: impl AsRef<Path>, |
| ... | @@ -847,58 +850,58 @@ impl Section { | ... | @@ -847,58 +850,58 @@ impl Section { |
| 847 | ) -> Result<Self> { | 850 | ) -> Result<Self> { |
| 848 | let replica = Arc::new(replica); | 851 | let replica = Arc::new(replica); |
| 849 | let (sender, events) = mpsc::channel(); | 852 | let (sender, events) = mpsc::channel(); |
| 850 | let (saved_sender, saved) = mpsc::channel(); | 853 | let (saves, saved) = mpsc::channel(); |
| 851 | let notify = Arc::new(Mutex::new(notify)); | 854 | let notify = Mutex::new(notify); |
| 852 | let signal = move || { | 855 | let notify: Notify = Arc::new(move || { |
| 853 | if let Ok(notify) = notify.lock() { | 856 | if let Ok(notify) = notify.lock() { |
| 854 | notify(); | 857 | notify(); |
| 855 | } | 858 | } |
| 856 | }; | 859 | }); |
| 857 | let (saves, requests) = mpsc::channel(); | 860 | let worker = { |
| 858 | let unsaved = Arc::new(AtomicUsize::new(0)); | 861 | let (sender, notify) = (sender.clone(), Arc::clone(&notify)); |
| 859 | let saver = { | 862 | replica.start_sync(Duration::from_secs(2), connect, move |result| { |
| 860 | let (replica, signal) = (Arc::clone(&replica), signal.clone()); | 863 | let mut events = Vec::new(); |
| 861 | let unsaved = Arc::clone(&unsaved); | 864 | match result { |
| 862 | thread::Builder::new() | 865 | Ok(synced) => { |
| 863 | .name("onestore-save".into()) | 866 | if !synced.changed.is_empty() { |
| 864 | .spawn(move || { | 867 | events.push(Event::Changed(synced.changed.clone())); |
| 865 | save_pages(&replica, &requests, |outcome, count| { | 868 | events.push(Event::Refreshed); |
| 866 | unsaved.fetch_sub(count, Ordering::Release); | ||
| 867 | if saved_sender.send(outcome).is_ok() { | ||
| 868 | signal(); | ||
| 869 | } | 869 | } |
| 870 | }); | 870 | if let Some((id, status)) = &synced.edit { |
| 871 | })? | 871 | events.push(Event::Attempt { |
| 872 | }; | 872 | id: *id, |
| 873 | let worker = replica.start_sync(Duration::from_secs(2), connect, move |result| { | 873 | status: status.clone(), |
| 874 | let event = match result { | 874 | }); |
| 875 | Ok(None) => Event::Refreshed, | 875 | } |
| 876 | Ok(Some((id, status))) => Event::Attempt { | 876 | } |
| 877 | id: *id, | 877 | Err(Error::RemoteIo(error)) => { |
| 878 | status: status.clone(), | 878 | events.push(Event::Unreachable(match error.raw_os_error() { |
| 879 | }, | 879 | Some(code) => io::Error::from_raw_os_error(code), |
| 880 | Err(Error::RemoteIo(error)) => Event::Unreachable(match error.raw_os_error() { | 880 | None => io::Error::new(error.kind(), error.to_string()), |
| 881 | Some(code) => io::Error::from_raw_os_error(code), | 881 | })) |
| 882 | None => io::Error::new(error.kind(), error.to_string()), | 882 | } |
| 883 | }), | 883 | Err(Error::Remote(error)) => events.push(Event::Unreachable(io::Error::new( |
| 884 | Err(Error::Remote(error)) => { | 884 | error.error.kind(), |
| 885 | Event::Unreachable(io::Error::new(error.error.kind(), error.to_string())) | 885 | error.to_string(), |
| 886 | ))), | ||
| 887 | Err(error) => events.push(Event::Failed(error.to_string())), | ||
| 886 | } | 888 | } |
| 887 | Err(error) => Event::Failed(error.to_string()), | 889 | if !events.is_empty() && events.into_iter().all(|event| sender.send(event).is_ok()) |
| 888 | }; | 890 | { |
| 889 | if sender.send(event).is_ok() { | 891 | notify(); |
| 890 | signal(); | 892 | } |
| 891 | } | 893 | })? |
| 892 | })?; | 894 | }; |
| 893 | Ok(Self { | 895 | Ok(Self { |
| 894 | file, | 896 | file, |
| 895 | replica, | 897 | replica, |
| 896 | worker: Some(worker), | 898 | worker: Some(worker), |
| 897 | events, | 899 | events: Mutex::new(events), |
| 898 | saves: Some(saves), | 900 | sender, |
| 899 | saved, | 901 | notify, |
| 900 | unsaved, | 902 | saved: Mutex::new(saved), |
| 901 | saver: Some(saver), | 903 | saves, |
| 904 | unsaved: Arc::new(AtomicUsize::new(0)), | ||
| 902 | }) | 905 | }) |
| 903 | } | 906 | } |
| 904 | 907 | ||
| ... | @@ -910,111 +913,74 @@ impl Section { | ... | @@ -910,111 +913,74 @@ impl Section { |
| 910 | /// The section file identity, which internal links name as `section-id` | 913 | /// The section file identity, which internal links name as `section-id` |
| 911 | /// (`onestore::page::link::internal_link`). | 914 | /// (`onestore::page::link::internal_link`). |
| 912 | pub fn identity(&self) -> Result<[u8; 16]> { | 915 | pub fn identity(&self) -> Result<[u8; 16]> { |
| 913 | Ok(Store::parse(&self.replica.snapshot()?)?.header.file_id) | 916 | self.replica.identity() |
| 914 | } | 917 | } |
| 915 | 918 | ||
| 916 | /// Page spaces, titles and outline levels (1 at the top) in section order, from the | 919 | /// Applies an edit without waiting: it becomes durable on the section thread, which |
| 917 | /// local working image. | 920 | /// reports a refusal as `Event::Rejected`. Edits apply in the order they arrive. |
| 918 | pub fn pages(&self) -> Result<Vec<(ExGuid, String, u32)>> { | 921 | pub fn apply(&self, author: &str, edit: Edit) -> Result<()> { |
| 919 | let snapshot = self.replica.snapshot()?; | 922 | let (sender, notify) = (self.sender.clone(), Arc::clone(&self.notify)); |
| 920 | let store = Store::parse(&snapshot)?; | 923 | let root = self.replica.root; |
| 921 | let index = RevisionIndex::parse(&store)?; | 924 | let spaces = crate::queue::spaces(&edit, root); |
| 922 | let document = Document::parse(&index)?; | 925 | self.replica.submit( |
| 923 | let pages = document.pages()?; | 926 | author, |
| 924 | let mut spaces = std::collections::BTreeSet::new(); | 927 | edit, |
| 925 | pages | 928 | Box::new(move |result| { |
| 926 | .into_iter() | 929 | let event = match result { |
| 927 | .map(|(space, page)| { | 930 | Ok(_) => return, |
| 928 | if !spaces.insert(space) { | 931 | Err(Error::Rejected(error)) => Event::Rejected { |
| 929 | return Err(onestore::Error { | 932 | spaces, |
| 930 | offset: 0, | 933 | error: error.to_string(), |
| 931 | message: "Choose an object space containing one active page", | 934 | }, |
| 932 | } | 935 | Err(error) => Event::Failed(error.to_string()), |
| 933 | .into()); | 936 | }; |
| 937 | if sender.send(event).is_ok() { | ||
| 938 | notify(); | ||
| 934 | } | 939 | } |
| 935 | let (title, level) = Page::heading(document.active(space)?, page); | 940 | }), |
| 936 | Ok((space, title, level)) | 941 | ) |
| 937 | }) | ||
| 938 | .collect() | ||
| 939 | } | 942 | } |
| 940 | 943 | ||
| 944 | /// Page spaces, titles and outline levels (1 at the top) in section order, as the | ||
| 945 | /// local edits leave them. | ||
| 946 | pub fn pages(&self) -> Result<Vec<(ExGuid, String, u32)>> { | ||
| 947 | self.replica.pages() | ||
| 948 | } | ||
| 949 | |||
| 950 | /// The page to show or edit; O(page), for opening and reloading. | ||
| 941 | pub fn page(&self, space: ExGuid) -> Result<Page> { | 951 | pub fn page(&self, space: ExGuid) -> Result<Page> { |
| 942 | self.replica.page(space) | 952 | self.replica.page(space) |
| 943 | } | 953 | } |
| 944 | 954 | ||
| 945 | /// Copies a page (usually from another section) to the end of this section as a | 955 | /// Copies a page (usually from another section) to the end of this section under |
| 946 | /// creation and a save queued like the user's own edits, under fresh identities. | 956 | /// fresh identities, queued like the user's own edits. Returns the new page's space. |
| 947 | /// Returns the new page's space. Content outside the model refuses to copy. | 957 | /// Content outside the model refuses to copy. |
| 948 | pub fn import_page(&self, page: &Page, author: &str) -> Result<ExGuid> { | 958 | pub fn import_page(&self, page: &Page, author: &str) -> Result<ExGuid> { |
| 949 | let copy = page.copy()?; | ||
| 950 | let creation = PageCreation::new(None, Some(&page.title), author)?; | 959 | let creation = PageCreation::new(None, Some(&page.title), author)?; |
| 951 | self.replica | ||
| 952 | .create_page(&self.replica.snapshot()?, &creation)?; | ||
| 953 | let space = creation.space(); | 960 | let space = creation.space(); |
| 954 | loop { | 961 | self.replica.apply( |
| 955 | // The worker may publish the creation, and so replace the working image, | 962 | author, |
| 956 | // between reading it and saving against it. | 963 | Edit { |
| 957 | let source = self.replica.snapshot()?; | 964 | at: crate::now(), |
| 958 | let mut after = Page::from_space( | 965 | ops: vec![Op::Section(SectionOp::Import { |
| 959 | &Document::parse(&RevisionIndex::parse(&Store::parse(&source)?)?)?, | 966 | creation, |
| 960 | space, | 967 | page: page.copy()?, |
| 961 | )?; | 968 | })], |
| 962 | after | 969 | }, |
| 963 | .objects | 970 | )?; |
| 964 | .retain(|object| matches!(object, onestore::page::PageObject::Title(_))); | ||
| 965 | after.objects.extend( | ||
| 966 | copy.objects | ||
| 967 | .iter() | ||
| 968 | .filter(|object| !matches!(object, onestore::page::PageObject::Title(_))) | ||
| 969 | .cloned(), | ||
| 970 | ); | ||
| 971 | after.definitions = copy.definitions.clone(); | ||
| 972 | match self.replica.save(&source, space, &after, author) { | ||
| 973 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => continue, | ||
| 974 | result => result?, | ||
| 975 | }; | ||
| 976 | break; | ||
| 977 | } | ||
| 978 | self.wake(); | ||
| 979 | Ok(space) | 971 | Ok(space) |
| 980 | } | 972 | } |
| 981 | 973 | ||
| 982 | /// Removes pages permanently, queued like the user's own edits (a move across | 974 | /// Removes pages permanently, queued like the user's own edits (a move across |
| 983 | /// sections is `import_page` there, then this here). | 975 | /// sections is `import_page` there, then this here). |
| 984 | pub fn delete_pages(&self, pages: &[ExGuid]) -> Result<Option<u64>> { | 976 | pub fn delete_pages(&self, pages: &[ExGuid]) -> Result<u64> { |
| 985 | let id = self | 977 | self.replica.apply( |
| 986 | .replica | 978 | "", |
| 987 | .delete_pages(&self.replica.snapshot()?, pages)?; | 979 | Edit { |
| 988 | self.wake(); | 980 | at: crate::now(), |
| 989 | Ok(id) | 981 | ops: vec![Op::Section(SectionOp::Delete(pages.to_vec()))], |
| 990 | } | 982 | }, |
| 991 | 983 | ) | |
| 992 | /// Saves an edited page. `before` is the model the edit started from; a stored page | ||
| 993 | /// that differs from it means the section changed underneath the editor. | ||
| 994 | pub fn save(&self, space: ExGuid, before: &Page, after: &Page, author: &str) -> Result<Save> { | ||
| 995 | Ok(save(&self.replica, space, before, None, after, author)?.0) | ||
| 996 | } | ||
| 997 | |||
| 998 | /// `save` on the section's save thread, returning at once; `saved` reports the outcome. | ||
| 999 | /// Saves queue in order, and one whose `before` is the previous save's `after` continues | ||
| 1000 | /// it: pass each save's `after` as the next one's `before`. | ||
| 1001 | pub fn queue_save(&self, space: ExGuid, before: Page, after: Page, author: &str) -> Result<()> { | ||
| 1002 | let save = PageSave { | ||
| 1003 | space, | ||
| 1004 | before, | ||
| 1005 | after, | ||
| 1006 | author: author.to_owned(), | ||
| 1007 | }; | ||
| 1008 | self.unsaved.fetch_add(1, Ordering::Relaxed); | ||
| 1009 | self.saves | ||
| 1010 | .as_ref() | ||
| 1011 | .and_then(|saves| saves.send(save).ok()) | ||
| 1012 | .ok_or_else(|| io::Error::other("The save thread stopped").into()) | ||
| 1013 | } | ||
| 1014 | |||
| 1015 | /// Whether a queued save has yet to report, so the stored page may trail the editor's. | ||
| 1016 | pub fn saving(&self) -> bool { | ||
| 1017 | self.unsaved.load(Ordering::Acquire) > 0 | ||
| 1018 | } | 984 | } |
| 1019 | 985 | ||
| 1020 | pub fn status(&self, id: u64) -> Result<Option<EditStatus>> { | 986 | pub fn status(&self, id: u64) -> Result<Option<EditStatus>> { |
| ... | @@ -1025,78 +991,48 @@ impl Section { | ... | @@ -1025,78 +991,48 @@ impl Section { |
| 1025 | self.replica.pending() | 991 | self.replica.pending() |
| 1026 | } | 992 | } |
| 1027 | 993 | ||
| 1028 | /// Every queued edit with its state: pending, awaiting confirmation of a retained | 994 | /// The unpublished batch the remote no longer accepts, if any. Show `remote_page` |
| 1029 | /// attempt, or a conflict awaiting review. | 995 | /// against `page` for its space, then `resolve`. |
| 1030 | pub fn queue(&self) -> Result<Vec<QueuedEdit>> { | 996 | pub fn conflict(&self) -> Result<Option<Conflict>> { |
| 1031 | self.replica | 997 | self.replica.conflict() |
| 1032 | .queued()? | ||
| 1033 | .into_iter() | ||
| 1034 | .map(|(id, space)| { | ||
| 1035 | Ok(QueuedEdit { | ||
| 1036 | id, | ||
| 1037 | space, | ||
| 1038 | status: self.replica.status(id)?.unwrap_or(EditStatus::Pending), | ||
| 1039 | }) | ||
| 1040 | }) | ||
| 1041 | .collect() | ||
| 1042 | } | ||
| 1043 | |||
| 1044 | /// The queued edits whose publication conflicted with a remote change. | ||
| 1045 | pub fn conflicts(&self) -> Result<Vec<(QueuedEdit, ConflictKind)>> { | ||
| 1046 | Ok(self | ||
| 1047 | .queue()? | ||
| 1048 | .into_iter() | ||
| 1049 | .filter_map(|edit| match edit.status { | ||
| 1050 | EditStatus::Conflict(kind) => Some((edit, kind)), | ||
| 1051 | _ => None, | ||
| 1052 | }) | ||
| 1053 | .collect()) | ||
| 1054 | } | 998 | } |
| 1055 | 999 | ||
| 1056 | /// The page as last observed in the section file, for reviewing a conflict. | 1000 | /// The page as last observed in the section file, for reviewing a conflict. |
| 1057 | pub fn remote_page(&self, space: ExGuid) -> Result<Page> { | 1001 | pub fn remote_page(&self, space: ExGuid) -> Result<Page> { |
| 1058 | let snapshot = self.replica.remote_snapshot()?; | 1002 | let snapshot = self.replica.remote_snapshot()?; |
| 1059 | let store = Store::parse(&snapshot)?; | 1003 | let arena = onestore::Arena::default(); |
| 1060 | let index = RevisionIndex::parse(&store)?; | 1004 | Ok(onestore::Section::open(&arena, snapshot)?.page(space)?) |
| 1061 | Ok(Page::from_space(&Document::parse(&index)?, space)?) | ||
| 1062 | } | 1005 | } |
| 1063 | 1006 | ||
| 1064 | /// Resolves the oldest conflict with a page reviewed against `remote_page`; the | 1007 | /// Ends a conflict: `Mine` publishes the local page over the remote change, `Theirs` |
| 1065 | /// reviewed model publishes as a whole, keeping the edit's id. | 1008 | /// drops the local edits to the conflicted page (and its later ones). |
| 1066 | pub fn review(&self, id: u64, after: &Page) -> Result<()> { | 1009 | pub fn resolve(&self, id: u64, resolution: Resolution) -> Result<()> { |
| 1067 | let local = self.replica.snapshot()?; | 1010 | self.replica.resolve(id, resolution) |
| 1068 | let remote = self.replica.remote_snapshot()?; | ||
| 1069 | self.replica.review_page(id, &local, &remote, after)?; | ||
| 1070 | self.wake(); | ||
| 1071 | Ok(()) | ||
| 1072 | } | 1011 | } |
| 1073 | 1012 | ||
| 1074 | /// Retires an uncertain attempt after review, exporting the branch to `archive` | 1013 | /// Retires an uncertain attempt after review, exporting the queue to `archive` first: |
| 1075 | /// first: `Some(page)` continues from the reviewed page against `remote_page`, | 1014 | /// `Mine` publishes the local edits again, `Theirs` abandons them. Neither claims the |
| 1076 | /// `None` abandons the local branch. Neither claims the attempt was acknowledged. | 1015 | /// attempt was acknowledged. |
| 1077 | pub fn release(&self, id: u64, archive: impl AsRef<Path>, after: Option<&Page>) -> Result<()> { | 1016 | pub fn release( |
| 1078 | let local = self.replica.snapshot()?; | 1017 | &self, |
| 1079 | let remote = self.replica.remote_snapshot()?; | 1018 | id: u64, |
| 1080 | self.replica | 1019 | archive: impl AsRef<Path>, |
| 1081 | .release_attempt(id, &local, &remote, archive.as_ref(), after)?; | 1020 | resolution: Resolution, |
| 1082 | self.wake(); | 1021 | ) -> Result<()> { |
| 1083 | Ok(()) | 1022 | self.replica.release(id, archive.as_ref(), resolution) |
| 1084 | } | 1023 | } |
| 1085 | 1024 | ||
| 1086 | /// Captures both images, the queue and its states in a read-only archive. | 1025 | /// Captures the queue, its images and its states in a read-only archive. |
| 1087 | pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> { | 1026 | pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> { |
| 1088 | self.replica.export_recovery(path) | 1027 | self.replica.export_recovery(path) |
| 1089 | } | 1028 | } |
| 1090 | 1029 | ||
| 1091 | /// Events since the last poll, oldest first. | 1030 | /// Events since the last poll, oldest first. |
| 1092 | pub fn events(&self) -> Vec<Event> { | 1031 | pub fn events(&self) -> Vec<Event> { |
| 1093 | self.events.try_iter().collect() | 1032 | self.events |
| 1094 | } | 1033 | .lock() |
| 1095 | 1034 | .map(|events| events.try_iter().collect()) | |
| 1096 | /// Outcomes of queued saves since the last poll, oldest first; `notify` runs for each. | 1035 | .unwrap_or_default() |
| 1097 | /// Consecutive saves continuing one another finish as one, reported once. | ||
| 1098 | pub fn saved(&self) -> Vec<(ExGuid, std::result::Result<Save, String>)> { | ||
| 1099 | self.saved.try_iter().collect() | ||
| 1100 | } | 1036 | } |
| 1101 | 1037 | ||
| 1102 | /// Requests a synchronization attempt now. | 1038 | /// Requests a synchronization attempt now. |
| ... | @@ -1115,107 +1051,118 @@ impl Section { | ... | @@ -1115,107 +1051,118 @@ impl Section { |
| 1115 | /// Dropping instead requests cancellation without waiting; the worker retains | 1051 | /// Dropping instead requests cancellation without waiting; the worker retains |
| 1116 | /// cache ownership until that operation finishes. Remote calls must be bounded. | 1052 | /// cache ownership until that operation finishes. Remote calls must be bounded. |
| 1117 | pub fn close(mut self) -> Result<()> { | 1053 | pub fn close(mut self) -> Result<()> { |
| 1118 | drop(self.saves.take()); | ||
| 1119 | if let Some(saver) = self.saver.take() { | ||
| 1120 | saver | ||
| 1121 | .join() | ||
| 1122 | .map_err(|_| io::Error::other("The save thread panicked"))?; | ||
| 1123 | } | ||
| 1124 | match self.worker.take() { | 1054 | match self.worker.take() { |
| 1125 | Some(worker) => worker.stop(), | 1055 | Some(worker) => worker.stop(), |
| 1126 | None => Ok(()), | 1056 | None => Ok(()), |
| 1127 | } | 1057 | } |
| 1128 | } | 1058 | } |
| 1129 | } | ||
| 1130 | 1059 | ||
| 1131 | /// Saves `after` unless the stored page no longer matches `before`, returning the working | 1060 | // Old whole-page session API, kept until the application emits ops. |
| 1132 | /// image the save leaves. `known` is the image a previous save of `before` left: while the | 1061 | |
| 1133 | /// working image equals it the page is current unread, and otherwise the page `known` | 1062 | /// Saves an edited page. `before` is the model the edit started from; a stored page |
| 1134 | /// stores stands in for `before`. | 1063 | /// that differs from it means the section changed underneath the editor. |
| 1135 | fn save( | 1064 | pub fn save(&self, space: ExGuid, before: &Page, after: &Page, author: &str) -> Result<Save> { |
| 1136 | replica: &Replica, | 1065 | let outcome = self.replica.ask(|reply| crate::working::Request::Save { |
| 1137 | space: ExGuid, | ||
| 1138 | before: &Page, | ||
| 1139 | known: Option<&[u8]>, | ||
| 1140 | after: &Page, | ||
| 1141 | author: &str, | ||
| 1142 | ) -> Result<(Save, Vec<u8>)> { | ||
| 1143 | let stored = |image: &[u8]| -> Result<Page> { | ||
| 1144 | let store = Store::parse(image)?; | ||
| 1145 | Ok(Page::from_space( | ||
| 1146 | &Document::parse(&RevisionIndex::parse(&store)?)?, | ||
| 1147 | space, | 1066 | space, |
| 1148 | )?) | 1067 | before: before.clone(), |
| 1149 | }; | 1068 | after: after.clone(), |
| 1150 | loop { | 1069 | author: author.to_owned(), |
| 1151 | let snapshot = replica.snapshot()?; | 1070 | reply, |
| 1152 | if known != Some(snapshot.as_slice()) { | 1071 | })?; |
| 1153 | let current = stored(&snapshot)?; | 1072 | Ok(outcome.unwrap_or(Save::Unchanged)) |
| 1154 | let expected = match known { | ||
| 1155 | Some(image) => current == stored(image)?, | ||
| 1156 | None => current == *before, | ||
| 1157 | }; | ||
| 1158 | if !expected { | ||
| 1159 | return Ok((Save::Stale, snapshot)); | ||
| 1160 | } | ||
| 1161 | } | ||
| 1162 | match replica.save_image(&snapshot, space, after, author) { | ||
| 1163 | Ok((Some(id), image)) => return Ok((Save::Queued(id), image)), | ||
| 1164 | Ok((None, image)) => return Ok((Save::Unchanged, image)), | ||
| 1165 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {} | ||
| 1166 | Err(error) => return Err(error), | ||
| 1167 | } | ||
| 1168 | } | 1073 | } |
| 1169 | } | ||
| 1170 | 1074 | ||
| 1171 | /// Runs queued saves until the section drops its sender, folding each run of saves that | 1075 | /// `save` on the section thread, returning at once; `saved` reports the outcome. |
| 1172 | /// continue one another into one. | 1076 | /// Saves queue in order, and one whose `before` is the previous save's `after` |
| 1173 | fn save_pages( | 1077 | /// continues it: pass each save's `after` as the next one's `before`. |
| 1174 | replica: &Replica, | 1078 | pub fn queue_save(&self, space: ExGuid, before: Page, after: Page, author: &str) -> Result<()> { |
| 1175 | requests: &Receiver<PageSave>, | 1079 | let (saves, notify, unsaved) = ( |
| 1176 | report: impl Fn((ExGuid, std::result::Result<Save, String>), usize), | 1080 | self.saves.clone(), |
| 1177 | ) { | 1081 | Arc::clone(&self.notify), |
| 1178 | let mut queue = VecDeque::new(); | 1082 | Arc::clone(&self.unsaved), |
| 1179 | // The last save's page and the working image it left. | ||
| 1180 | let mut last: Option<(ExGuid, Page, Vec<u8>)> = None; | ||
| 1181 | loop { | ||
| 1182 | if queue.is_empty() { | ||
| 1183 | match requests.recv() { | ||
| 1184 | Ok(request) => queue.push_back(request), | ||
| 1185 | Err(_) => return, | ||
| 1186 | } | ||
| 1187 | } | ||
| 1188 | queue.extend(requests.try_iter()); | ||
| 1189 | let mut request = queue.pop_front().unwrap(); | ||
| 1190 | let mut count = 1; | ||
| 1191 | while let Some(next) = queue.front() | ||
| 1192 | && (next.space, &next.before, &next.author) | ||
| 1193 | == (request.space, &request.after, &request.author) | ||
| 1194 | { | ||
| 1195 | request.after = queue.pop_front().unwrap().after; | ||
| 1196 | count += 1; | ||
| 1197 | } | ||
| 1198 | let known = last | ||
| 1199 | .take() | ||
| 1200 | .filter(|(space, after, _)| *space == request.space && *after == request.before); | ||
| 1201 | let result = save( | ||
| 1202 | replica, | ||
| 1203 | request.space, | ||
| 1204 | &request.before, | ||
| 1205 | known.as_ref().map(|(_, _, image)| image.as_slice()), | ||
| 1206 | &request.after, | ||
| 1207 | &request.author, | ||
| 1208 | ); | 1083 | ); |
| 1209 | let save = match result { | 1084 | self.unsaved.fetch_add(1, Ordering::Relaxed); |
| 1210 | Ok((save, image)) => { | 1085 | self.replica.send(crate::working::Request::Save { |
| 1211 | if save != Save::Stale { | 1086 | space, |
| 1212 | last = Some((request.space, request.after, image)); | 1087 | before, |
| 1088 | after, | ||
| 1089 | author: author.to_owned(), | ||
| 1090 | reply: Box::new(move |outcome| { | ||
| 1091 | unsaved.fetch_sub(1, Ordering::Release); | ||
| 1092 | let outcome = match outcome { | ||
| 1093 | Ok(None) => return, | ||
| 1094 | Ok(Some(save)) => Ok(save), | ||
| 1095 | Err(error) => Err(error.to_string()), | ||
| 1096 | }; | ||
| 1097 | if saves.send((space, outcome)).is_ok() { | ||
| 1098 | notify(); | ||
| 1213 | } | 1099 | } |
| 1214 | Ok(save) | 1100 | }), |
| 1215 | } | 1101 | }) |
| 1216 | Err(error) => Err(error.to_string()), | 1102 | } |
| 1217 | }; | 1103 | |
| 1218 | report((request.space, save), count); | 1104 | /// Whether a queued save has yet to report, so the stored page may trail the editor's. |
| 1105 | pub fn saving(&self) -> bool { | ||
| 1106 | self.unsaved.load(Ordering::Acquire) > 0 | ||
| 1107 | } | ||
| 1108 | |||
| 1109 | /// Outcomes of queued saves since the last poll, oldest first; `notify` runs for each. | ||
| 1110 | /// Consecutive saves continuing one another finish as one, reported once. | ||
| 1111 | pub fn saved(&self) -> Vec<(ExGuid, std::result::Result<Save, String>)> { | ||
| 1112 | self.saved | ||
| 1113 | .lock() | ||
| 1114 | .map(|saved| saved.try_iter().collect()) | ||
| 1115 | .unwrap_or_default() | ||
| 1116 | } | ||
| 1117 | |||
| 1118 | /// Every queued edit with its state: pending, awaiting confirmation of a retained | ||
| 1119 | /// attempt, or a conflict awaiting review. `space` is the first page it edits. | ||
| 1120 | pub fn queue(&self) -> Result<Vec<QueuedEdit>> { | ||
| 1121 | self.replica | ||
| 1122 | .pending()? | ||
| 1123 | .into_iter() | ||
| 1124 | .map(|edit| { | ||
| 1125 | Ok(QueuedEdit { | ||
| 1126 | id: edit.id, | ||
| 1127 | space: edit | ||
| 1128 | .edit | ||
| 1129 | .ops | ||
| 1130 | .iter() | ||
| 1131 | .find_map(|op| match op { | ||
| 1132 | Op::Page { space, .. } => Some(*space), | ||
| 1133 | Op::Section(_) => None, | ||
| 1134 | }) | ||
| 1135 | .unwrap_or(self.replica.root), | ||
| 1136 | status: self.replica.status(edit.id)?.unwrap_or(EditStatus::Pending), | ||
| 1137 | }) | ||
| 1138 | }) | ||
| 1139 | .collect() | ||
| 1140 | } | ||
| 1141 | |||
| 1142 | /// The conflict as a queue entry, for the review dialog. | ||
| 1143 | pub fn conflicts(&self) -> Result<Vec<(QueuedEdit, ConflictKind)>> { | ||
| 1144 | Ok(self | ||
| 1145 | .replica | ||
| 1146 | .conflict()? | ||
| 1147 | .map(|conflict| { | ||
| 1148 | ( | ||
| 1149 | QueuedEdit { | ||
| 1150 | id: conflict.id, | ||
| 1151 | space: conflict.space, | ||
| 1152 | status: EditStatus::Conflict(conflict.kind), | ||
| 1153 | }, | ||
| 1154 | conflict.kind, | ||
| 1155 | ) | ||
| 1156 | }) | ||
| 1157 | .into_iter() | ||
| 1158 | .collect()) | ||
| 1159 | } | ||
| 1160 | |||
| 1161 | /// Resolves the conflict holding `id` with a page reviewed against `remote_page`: the | ||
| 1162 | /// remote page is rewritten to `after`. | ||
| 1163 | pub fn review(&self, id: u64, after: &Page) -> Result<()> { | ||
| 1164 | self.replica | ||
| 1165 | .resolve_with(id, Resolution::Mine, Some(after.clone())) | ||
| 1219 | } | 1166 | } |
| 1220 | } | 1167 | } |
| 1221 | 1168 |
crates/notebook/src/sync.rs+515-490| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | use super::*; | 1 | use super::*; |
| 2 | use onestore::{CommitError, CommitState, Stamp, Transaction}; | 2 | use crate::working::{Rebased, Request, Resolve, Sealed}; |
| 3 | use onestore::{CommitError, CommitState, RevisionIndex, Stamp, Store, Transaction}; | ||
| 3 | use rusqlite::OptionalExtension; | 4 | use rusqlite::OptionalExtension; |
| 4 | use std::{ | 5 | use std::{ |
| 5 | collections::BTreeMap, | 6 | collections::BTreeMap, |
| 6 | sync::{MutexGuard, TryLockError, atomic::Ordering}, | 7 | sync::{MutexGuard, TryLockError}, |
| 7 | }; | 8 | }; |
| 8 | 9 | ||
| 9 | /// A single remote file with fresh reads and native-compatible guarded publication. | 10 | /// A single remote file with fresh reads and native-compatible guarded publication. |
| ... | @@ -25,14 +26,32 @@ pub trait Remote { | ... | @@ -25,14 +26,32 @@ pub trait Remote { |
| 25 | pub enum ConflictKind { | 26 | pub enum ConflictKind { |
| 26 | /// The edited page or a selected page no longer exists remotely. | 27 | /// The edited page or a selected page no longer exists remotely. |
| 27 | TargetUnavailable = 0, | 28 | TargetUnavailable = 0, |
| 28 | /// The remote image no longer accepts the intent's publication. | 29 | /// The remote section no longer accepts the edit. |
| 29 | UnsupportedEdit = 1, | 30 | UnsupportedEdit = 1, |
| 30 | /// Page order or indentation changed remotely in a way the batch cannot absorb. | 31 | /// Page order or indentation changed remotely in a way the batch cannot absorb. |
| 31 | StructureChanged = 2, | 32 | StructureChanged = 2, |
| 32 | /// The remote page changed where the local model changed too; review is required. | 33 | /// The remote page changed where the local edits changed it too; review is required. |
| 33 | ContentChanged = 3, | 34 | ContentChanged = 3, |
| 34 | } | 35 | } |
| 35 | 36 | ||
| 37 | impl ConflictKind { | ||
| 38 | fn from_stored(kind: i64) -> Result<Self> { | ||
| 39 | Ok(match kind { | ||
| 40 | 0 => Self::TargetUnavailable, | ||
| 41 | 1 => Self::UnsupportedEdit, | ||
| 42 | 2 => Self::StructureChanged, | ||
| 43 | 3 => Self::ContentChanged, | ||
| 44 | _ => { | ||
| 45 | return Err(io::Error::new( | ||
| 46 | io::ErrorKind::InvalidData, | ||
| 47 | "Unknown cached conflict kind", | ||
| 48 | ) | ||
| 49 | .into()); | ||
| 50 | } | ||
| 51 | }) | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 36 | #[derive(Debug, Clone, PartialEq, Eq)] | 55 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 37 | pub enum EditStatus { | 56 | pub enum EditStatus { |
| 38 | Pending, | 57 | Pending, |
| ... | @@ -46,499 +65,534 @@ pub enum EditStatus { | ... | @@ -46,499 +65,534 @@ pub enum EditStatus { |
| 46 | revision: ExGuid, | 65 | revision: ExGuid, |
| 47 | }, | 66 | }, |
| 48 | /// Retired unpublished by a reviewed release; the archive at this path holds the | 67 | /// Retired unpublished by a reviewed release; the archive at this path holds the |
| 49 | /// intent, its attempt evidence and both images. | 68 | /// edit, its attempt evidence and both images. |
| 50 | Archived { | 69 | Archived { |
| 51 | archive: String, | 70 | archive: String, |
| 52 | }, | 71 | }, |
| 53 | } | 72 | } |
| 54 | 73 | ||
| 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)] | ||
| 77 | pub struct Synced { | ||
| 78 | pub edit: Option<(u64, EditStatus)>, | ||
| 79 | pub changed: Vec<ExGuid>, | ||
| 80 | } | ||
| 81 | |||
| 82 | /// The queue as a synchronization step starts it. | ||
| 83 | struct 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 | |||
| 92 | struct Blocked { | ||
| 93 | batch: i64, | ||
| 94 | attempted: bool, | ||
| 95 | conflict: Option<ConflictKind>, | ||
| 96 | revisions: Option<BTreeMap<ExGuid, ExGuid>>, | ||
| 97 | } | ||
| 98 | |||
| 99 | fn 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 | |||
| 137 | fn 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. | ||
| 154 | fn 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. | ||
| 173 | fn 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 | |||
| 55 | impl Replica { | 181 | impl Replica { |
| 56 | /// Returns a durable receipt or the persisted state of a locally acknowledged edit. | 182 | /// Returns a durable receipt or the persisted state of a locally acknowledged edit. |
| 57 | pub fn status(&self, id: u64) -> Result<Option<EditStatus>> { | 183 | pub fn status(&self, id: u64) -> Result<Option<EditStatus>> { |
| 58 | let connection = self | 184 | status(&*self.lock()?, id) |
| 59 | .connection | ||
| 60 | .lock() | ||
| 61 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 62 | status(&connection, id) | ||
| 63 | } | 185 | } |
| 64 | 186 | ||
| 65 | /// The last observed remote image, retained alongside the complete local working image. | 187 | /// The last observed remote image. Observation alone does not acknowledge any pending |
| 66 | /// Observation alone does not acknowledge any pending edit's remote durability. | 188 | /// edit's remote durability. |
| 67 | pub fn remote_snapshot(&self) -> Result<Vec<u8>> { | 189 | pub fn remote_snapshot(&self) -> Result<Vec<u8>> { |
| 68 | let connection = self | 190 | let connection = self.lock()?; |
| 69 | .connection | 191 | let image = match base::read(&connection, base::Image::Remote)? { |
| 70 | .lock() | 192 | Some(image) => Some(image), |
| 71 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | 193 | None => base::read(&connection, base::Image::Base)?, |
| 72 | images::base(&connection) | 194 | }; |
| 195 | Ok(image.ok_or_else(|| { | ||
| 196 | io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image") | ||
| 197 | })?) | ||
| 198 | } | ||
| 199 | |||
| 200 | /// The unpublished batch the remote no longer accepts, if any. | ||
| 201 | pub fn conflict(&self) -> Result<Option<Conflict>> { | ||
| 202 | let connection = self.lock()?; | ||
| 203 | let row: Option<(i64, i64, String)> = connection | ||
| 204 | .query_row( | ||
| 205 | "SELECT id, conflict, space FROM batches WHERE conflict IS NOT NULL ORDER BY id LIMIT 1", | ||
| 206 | [], | ||
| 207 | |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), | ||
| 208 | ) | ||
| 209 | .optional()?; | ||
| 210 | row.map(|(batch, kind, space)| { | ||
| 211 | Ok(Conflict { | ||
| 212 | id: newest(&connection, batch)?, | ||
| 213 | space: space.parse()?, | ||
| 214 | kind: ConflictKind::from_stored(kind)?, | ||
| 215 | }) | ||
| 216 | }) | ||
| 217 | .transpose() | ||
| 73 | } | 218 | } |
| 74 | 219 | ||
| 75 | /// Reconciles one pending edit, or refreshes the working image when the queue is empty. | 220 | /// Publishes the oldest unpublished batch, rebasing the queue first when the remote |
| 76 | /// Network I/O holds synchronization ownership without holding the cache mutex. | 221 | /// changed. Reads the remote image only when its stamp moved; network I/O holds |
| 77 | /// Uncertain edits are never replayed; retired formatting may confirm its complete observed effect. | 222 | /// synchronization ownership without holding the cache mutex. Uncertain attempts are |
| 78 | pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Option<(u64, EditStatus)>> { | 223 | /// never replayed. |
| 224 | pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Synced> { | ||
| 79 | let _owner = self.sync_owner()?; | 225 | let _owner = self.sync_owner()?; |
| 80 | struct Step<'a>(&'a Replica); | 226 | let state = state(&*self.lock()?)?; |
| 81 | impl Drop for Step<'_> { | 227 | let observed = remote.stamp().map_err(Error::RemoteIo)?; |
| 82 | fn drop(&mut self) { | 228 | if let (Some(observed), Some(blocked)) = (&observed, &state.blocked) |
| 83 | self.0.in_flight.store(0, Ordering::Release); | 229 | && Some(observed) == state.remote.as_ref().or(Some(&state.base)) |
| 84 | } | 230 | { |
| 231 | // Nothing new to decide: the remote is as it was when the batch blocked. | ||
| 232 | let id = newest(&*self.lock()?, blocked.batch)?; | ||
| 233 | return Ok(Synced { | ||
| 234 | edit: Some(( | ||
| 235 | id, | ||
| 236 | status(&*self.lock()?, id)?.unwrap_or(EditStatus::Pending), | ||
| 237 | )), | ||
| 238 | changed: Vec::new(), | ||
| 239 | }); | ||
| 85 | } | 240 | } |
| 86 | let _step = Step(self); | 241 | let image = match observed { |
| 87 | let base = self.remote_snapshot()?; | 242 | Some(observed) if observed == state.base => None, |
| 88 | let changed = match remote.stamp().map_err(Error::RemoteIo)? { | 243 | _ => { |
| 89 | Some(stamp) if stamp == Stamp::of(&base)? => None, | 244 | let image = remote.read().map_err(Error::RemoteIo)?; |
| 90 | _ => Some(remote.read().map_err(Error::RemoteIo)?).filter(|read| *read != base), | 245 | // A read image is compared whole: a stamp stands for it only when read alone. |
| 91 | }; | 246 | let base = base::read(&*self.lock()?, base::Image::Base)?; |
| 92 | // An unchanged remote is the base image, validated when it was stored. | 247 | (base.as_deref() != Some(&image[..])).then_some(image) |
| 93 | let identity = changed.as_deref().map(validate).transpose()?; | ||
| 94 | let snapshot = changed.unwrap_or(base); | ||
| 95 | let (intent, attempted) = { | ||
| 96 | let mut connection = self | ||
| 97 | .connection | ||
| 98 | .lock() | ||
| 99 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 100 | let transaction = | ||
| 101 | connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 102 | if identity.is_none() && images::settled(&transaction)? { | ||
| 103 | return Ok(None); | ||
| 104 | } | ||
| 105 | let (base, working) = images::both(&transaction)?; | ||
| 106 | if let Some(identity) = identity | ||
| 107 | && RevisionIndex::parse(&Store::parse(&base)?)?.root != identity | ||
| 108 | { | ||
| 109 | return Err(io::Error::new( | ||
| 110 | io::ErrorKind::InvalidInput, | ||
| 111 | "Remote snapshot belongs to another document", | ||
| 112 | ) | ||
| 113 | .into()); | ||
| 114 | } | 248 | } |
| 115 | let intent = { | ||
| 116 | let mut query = transaction | ||
| 117 | .prepare("SELECT id, space, operation FROM edits ORDER BY id LIMIT 1")?; | ||
| 118 | let mut rows = query.query([])?; | ||
| 119 | rows.next()?.map(pending_edit).transpose()? | ||
| 120 | }; | ||
| 121 | let Some(intent) = intent else { | ||
| 122 | images::set_base(&transaction, &base, &snapshot, &snapshot)?; | ||
| 123 | transaction.commit()?; | ||
| 124 | return Ok(None); | ||
| 125 | }; | ||
| 126 | self.in_flight.store( | ||
| 127 | i64::try_from(intent.id).map_err(io::Error::other)?, | ||
| 128 | Ordering::Release, | ||
| 129 | ); | ||
| 130 | let attempted = transaction | ||
| 131 | .query_row( | ||
| 132 | "SELECT revisions FROM attempt WHERE edit_id=?1", | ||
| 133 | [i64::try_from(intent.id).map_err(io::Error::other)?], | ||
| 134 | |row| row.get::<_, String>(0), | ||
| 135 | ) | ||
| 136 | .optional()?; | ||
| 137 | images::set_base(&transaction, &base, &snapshot, &working)?; | ||
| 138 | transaction.commit()?; | ||
| 139 | (intent, attempted) | ||
| 140 | }; | 249 | }; |
| 141 | if let Some(encoded) = attempted { | 250 | let mut changed = Vec::new(); |
| 142 | let revisions = attempted_revisions(&encoded, intent.space)?; | 251 | if let Some(image) = image { |
| 143 | let mut revision = revisions[&intent.space]; | 252 | if let Some(Blocked { |
| 144 | let store = Store::parse(&snapshot)?; | 253 | batch, |
| 145 | let index = RevisionIndex::parse(&store)?; | 254 | attempted: true, |
| 146 | if !revisions.iter().all(|(space, revision)| { | 255 | revisions, |
| 147 | index | 256 | .. |
| 148 | .spaces | 257 | }) = &state.blocked |
| 149 | .get(space) | 258 | { |
| 150 | .is_some_and(|space| space.revisions.contains_key(revision)) | 259 | let id = newest(&*self.lock()?, *batch)?; |
| 151 | }) { | 260 | let revisions = revisions.clone().unwrap_or_default(); |
| 152 | // A retired attempt confirms only when its complete effect is observed. | 261 | let store = Store::parse(&image)?; |
| 153 | let satisfied = match &intent.operation { | 262 | if !store.checksum_mismatches.is_empty() { |
| 154 | Operation::Page(edit) if revisions.len() == 1 => { | 263 | return Err(io::Error::new( |
| 155 | page_of(&snapshot, intent.space)?.is_some_and(|page| page == edit.after) | 264 | io::ErrorKind::InvalidData, |
| 265 | "Notebook transaction checksum damage", | ||
| 266 | ) | ||
| 267 | .into()); | ||
| 268 | } | ||
| 269 | let index = RevisionIndex::parse(&store)?; | ||
| 270 | index.validate_current()?; | ||
| 271 | if index.root != self.root { | ||
| 272 | return Err(io::Error::new( | ||
| 273 | io::ErrorKind::InvalidInput, | ||
| 274 | "Remote snapshot belongs to another document", | ||
| 275 | ) | ||
| 276 | .into()); | ||
| 277 | } | ||
| 278 | let observed = revisions.iter().all(|(space, revision)| { | ||
| 279 | index | ||
| 280 | .spaces | ||
| 281 | .get(space) | ||
| 282 | .is_some_and(|space| space.revisions.contains_key(revision)) | ||
| 283 | }); | ||
| 284 | let sealed = working::sealed(&*self.lock()?)? | ||
| 285 | .filter(|sealed| sealed.batch == *batch) | ||
| 286 | .and_then(|sealed| sealed.transaction); | ||
| 287 | // Without its revisions the attempt still counts once the remote holds | ||
| 288 | // every page it changed as it changed them; its receipts then name the | ||
| 289 | // remote's revisions. | ||
| 290 | let receipts = if observed { | ||
| 291 | None | ||
| 292 | } else { | ||
| 293 | let equal = self.holds(&image, sealed.as_ref(), &revisions)?; | ||
| 294 | if !equal { | ||
| 295 | base::write(&*self.lock()?, base::Image::Remote, &image)?; | ||
| 296 | return Ok(Synced { | ||
| 297 | edit: Some(( | ||
| 298 | id, | ||
| 299 | status(&*self.lock()?, id)?.unwrap_or(EditStatus::Pending), | ||
| 300 | )), | ||
| 301 | changed, | ||
| 302 | }); | ||
| 156 | } | 303 | } |
| 157 | _ => false, | 304 | Some( |
| 305 | revisions | ||
| 306 | .keys() | ||
| 307 | .filter_map(|space| Some((*space, index.active(*space).ok()?))) | ||
| 308 | .collect(), | ||
| 309 | ) | ||
| 158 | }; | 310 | }; |
| 159 | if !satisfied { | 311 | if let Err(error) = remote.confirm(&image) { |
| 160 | return Ok(Some(( | 312 | if error.state == CommitState::Committed { |
| 161 | intent.id, | 313 | self.acknowledge(*batch, sealed.as_ref(), receipts.as_ref())?; |
| 162 | EditStatus::AwaitingConfirmation { revision }, | 314 | } |
| 163 | ))); | 315 | return Err(error.into()); |
| 164 | } | 316 | } |
| 165 | revision = index.active(intent.space)?; | 317 | self.acknowledge(*batch, sealed.as_ref(), receipts.as_ref())?; |
| 318 | let revision = self.receipt(id)?; | ||
| 319 | changed = self.rebase(Some(image), None)?.unwrap_or_default(); | ||
| 320 | return Ok(Synced { | ||
| 321 | edit: Some((id, EditStatus::Published { revision })), | ||
| 322 | changed, | ||
| 323 | }); | ||
| 166 | } | 324 | } |
| 167 | if let Err(error) = remote.confirm(&snapshot) { | 325 | match self.rebase(Some(image), None)? { |
| 168 | if error.state == CommitState::Committed { | 326 | Ok(spaces) => changed = spaces, |
| 169 | self.acknowledge(intent.id, revision, &snapshot)?; | 327 | Err(conflict) => { |
| 328 | return Ok(Synced { | ||
| 329 | edit: Some((conflict.id, EditStatus::Conflict(conflict.kind))), | ||
| 330 | changed, | ||
| 331 | }); | ||
| 170 | } | 332 | } |
| 171 | return Err(error.into()); | ||
| 172 | } | 333 | } |
| 173 | self.acknowledge(intent.id, revision, &snapshot)?; | 334 | } else if let Some(blocked) = &state.blocked { |
| 174 | return Ok(Some((intent.id, EditStatus::Published { revision }))); | 335 | if state.remote.is_some() { |
| 336 | // The remote is back at the base: what blocked the queue is gone. | ||
| 337 | let connection = self.lock()?; | ||
| 338 | base::clear(&connection, base::Image::Remote)?; | ||
| 339 | if blocked.conflict.is_some() { | ||
| 340 | connection.execute("UPDATE batches SET conflict=NULL, space=NULL", [])?; | ||
| 341 | } | ||
| 342 | } | ||
| 343 | if blocked.attempted { | ||
| 344 | let id = newest(&*self.lock()?, blocked.batch)?; | ||
| 345 | return Ok(Synced { | ||
| 346 | edit: Some(( | ||
| 347 | id, | ||
| 348 | status(&*self.lock()?, id)?.unwrap_or(EditStatus::Pending), | ||
| 349 | )), | ||
| 350 | changed, | ||
| 351 | }); | ||
| 352 | } | ||
| 353 | if let Some(kind) = blocked.conflict | ||
| 354 | && state.remote.is_none() | ||
| 355 | { | ||
| 356 | let id = newest(&*self.lock()?, blocked.batch)?; | ||
| 357 | return Ok(Synced { | ||
| 358 | edit: Some((id, EditStatus::Conflict(kind))), | ||
| 359 | changed, | ||
| 360 | }); | ||
| 361 | } | ||
| 362 | } else if !state.queued { | ||
| 363 | return Ok(Synced::default()); | ||
| 175 | } | 364 | } |
| 176 | let candidate = match &intent.operation { | 365 | // The cache lock is released before the section thread, which takes it, seals. |
| 177 | Operation::Page(edit) => page_of(&snapshot, intent.space)? | 366 | let waiting = working::sealed(&*self.lock()?)?; |
| 178 | .ok_or(ConflictKind::TargetUnavailable) | 367 | let sealed = match waiting { |
| 179 | .and_then(|current| { | 368 | Some(sealed) => { |
| 180 | let target = if current == edit.before { | 369 | if sealed.transaction.is_some() { |
| 181 | edit.after.clone() | 370 | self.lock()? |
| 182 | } else { | 371 | .execute("UPDATE batches SET attempted=1 WHERE id=?1", [sealed.batch])?; |
| 183 | crate::merge::merge(&edit.before, &edit.after, &current) | ||
| 184 | .ok_or(ConflictKind::ContentChanged)? | ||
| 185 | }; | ||
| 186 | PreparedEdit::page(&snapshot, intent.space, &target, &edit.author) | ||
| 187 | .map_err(|_| ConflictKind::UnsupportedEdit) | ||
| 188 | }), | ||
| 189 | Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page) | ||
| 190 | .map_err(|_| ConflictKind::StructureChanged), | ||
| 191 | Operation::Pages(edit) => edit.prepare(&snapshot, intent.space)?, | ||
| 192 | Operation::DeletePages(pages) => { | ||
| 193 | let store = Store::parse(&snapshot)?; | ||
| 194 | let index = RevisionIndex::parse(&store)?; | ||
| 195 | if pages.iter().any(|page| { | ||
| 196 | index | ||
| 197 | .spaces | ||
| 198 | .get(page) | ||
| 199 | .is_none_or(|space| space.labels.is_empty()) | ||
| 200 | }) || Document::parse(&index)? | ||
| 201 | .pages()? | ||
| 202 | .iter() | ||
| 203 | .filter(|(sid, _)| pages.contains(sid)) | ||
| 204 | .count() | ||
| 205 | != pages.len() | ||
| 206 | { | ||
| 207 | Err(ConflictKind::TargetUnavailable) | ||
| 208 | } else { | ||
| 209 | PreparedEdit::delete_pages_permanently(&snapshot, pages) | ||
| 210 | .map_err(|_| ConflictKind::UnsupportedEdit) | ||
| 211 | } | 372 | } |
| 373 | Some(sealed) | ||
| 212 | } | 374 | } |
| 375 | None => self.ask(|reply| Request::Seal { reply })?, | ||
| 213 | }; | 376 | }; |
| 214 | let prepared = match candidate { | 377 | let Some(Sealed { batch, transaction }) = sealed else { |
| 215 | Ok(prepared) => prepared, | 378 | return Ok(Synced { |
| 216 | Err(kind) => { | 379 | edit: None, |
| 217 | let connection = self | 380 | changed, |
| 218 | .connection | 381 | }); |
| 219 | .lock() | ||
| 220 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 221 | connection.execute("INSERT INTO conflicts(edit_id, kind) VALUES (?1, ?2) ON CONFLICT(edit_id) DO UPDATE SET kind=excluded.kind", params![i64::try_from(intent.id).map_err(io::Error::other)?, kind as i64])?; | ||
| 222 | return Ok(Some((intent.id, EditStatus::Conflict(kind)))); | ||
| 223 | } | ||
| 224 | }; | 382 | }; |
| 225 | if prepared.as_bytes() == snapshot { | 383 | let id = newest(&*self.lock()?, batch)?; |
| 226 | let store = Store::parse(&snapshot)?; | 384 | let Some(transaction) = transaction else { |
| 227 | let index = RevisionIndex::parse(&store)?; | 385 | // Edits that changed nothing are published once the remote's image is durable. |
| 228 | let revision = index.active(intent.space)?; | 386 | let base = base::read(&*self.lock()?, base::Image::Base)?.unwrap_or_default(); |
| 229 | if let Err(error) = remote.confirm(&snapshot) { | 387 | if let Err(error) = remote.confirm(&base) { |
| 230 | if error.state == CommitState::Committed { | 388 | if error.state == CommitState::Committed { |
| 231 | self.acknowledge(intent.id, revision, &snapshot)?; | 389 | self.acknowledge(batch, None, None)?; |
| 232 | } | 390 | } |
| 233 | return Err(error.into()); | 391 | return Err(error.into()); |
| 234 | } | 392 | } |
| 235 | self.acknowledge(intent.id, revision, &snapshot)?; | 393 | self.acknowledge(batch, None, None)?; |
| 236 | return Ok(Some((intent.id, EditStatus::Published { revision }))); | 394 | let revision = self.receipt(id)?; |
| 237 | } | 395 | return Ok(Synced { |
| 238 | // Once acknowledged this image is the base, which polls trust without validating. | 396 | edit: Some((id, EditStatus::Published { revision })), |
| 239 | validate(prepared.as_bytes())?; | 397 | changed, |
| 240 | let store = Store::parse(prepared.as_bytes())?; | 398 | }); |
| 241 | let index = RevisionIndex::parse(&store)?; | 399 | }; |
| 242 | let revision = index.active(intent.space)?; | 400 | match remote.publish(&transaction) { |
| 243 | let before_store = Store::parse(&snapshot)?; | ||
| 244 | let before = RevisionIndex::parse(&before_store)?; | ||
| 245 | let mut revisions: BTreeMap<_, _> = index | ||
| 246 | .spaces | ||
| 247 | .iter() | ||
| 248 | .filter_map(|(sid, space)| { | ||
| 249 | let revision = *space.labels.get(&(ExGuid::default(), 1))?; | ||
| 250 | (before | ||
| 251 | .spaces | ||
| 252 | .get(sid) | ||
| 253 | .and_then(|s| s.labels.get(&(ExGuid::default(), 1))) | ||
| 254 | != Some(&revision)) | ||
| 255 | .then_some((*sid, revision)) | ||
| 256 | }) | ||
| 257 | .collect(); | ||
| 258 | // The receipt's space can be unchanged in a multi-space edit. | ||
| 259 | revisions.insert(intent.space, revision); | ||
| 260 | { | ||
| 261 | let mut connection = self | ||
| 262 | .connection | ||
| 263 | .lock() | ||
| 264 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 265 | let transaction = | ||
| 266 | connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 267 | transaction.execute( | ||
| 268 | "DELETE FROM conflicts WHERE edit_id=?1", | ||
| 269 | [i64::try_from(intent.id).map_err(io::Error::other)?], | ||
| 270 | )?; | ||
| 271 | transaction.execute( | ||
| 272 | "INSERT INTO attempt(id, edit_id, revisions) VALUES (1, ?1, ?2)", | ||
| 273 | params![ | ||
| 274 | i64::try_from(intent.id).map_err(io::Error::other)?, | ||
| 275 | serde_json::to_string(&revisions).map_err(io::Error::other)? | ||
| 276 | ], | ||
| 277 | )?; | ||
| 278 | transaction.commit()?; | ||
| 279 | } | ||
| 280 | match remote.publish(&prepared.transaction()) { | ||
| 281 | Ok(()) => {} | 401 | Ok(()) => {} |
| 282 | Err(error) if error.state == CommitState::NotCommitted => { | 402 | Err(error) if error.state == CommitState::NotCommitted => { |
| 283 | let connection = self | 403 | self.lock()? |
| 284 | .connection | 404 | .execute("UPDATE batches SET attempted=0 WHERE id=?1", [batch])?; |
| 285 | .lock() | ||
| 286 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 287 | connection.execute( | ||
| 288 | "DELETE FROM attempt WHERE edit_id=?1", | ||
| 289 | [i64::try_from(intent.id).map_err(io::Error::other)?], | ||
| 290 | )?; | ||
| 291 | return Err(error.into()); | 405 | return Err(error.into()); |
| 292 | } | 406 | } |
| 293 | Err(error) if error.state == CommitState::Committed => { | 407 | Err(error) if error.state == CommitState::Committed => { |
| 294 | self.acknowledge(intent.id, revision, prepared.as_bytes())?; | 408 | self.acknowledge(batch, Some(&transaction), None)?; |
| 295 | return Err(error.into()); | 409 | return Err(error.into()); |
| 296 | } | 410 | } |
| 297 | Err(error) => return Err(error.into()), | 411 | Err(error) => return Err(error.into()), |
| 298 | } | 412 | } |
| 299 | self.acknowledge(intent.id, revision, prepared.as_bytes())?; | 413 | self.acknowledge(batch, Some(&transaction), None)?; |
| 300 | Ok(Some((intent.id, EditStatus::Published { revision }))) | 414 | let revision = self.receipt(id)?; |
| 415 | Ok(Synced { | ||
| 416 | edit: Some((id, EditStatus::Published { revision })), | ||
| 417 | changed, | ||
| 418 | }) | ||
| 301 | } | 419 | } |
| 302 | 420 | ||
| 303 | /// Repositions an unattempted page-creation conflict, retaining dependent object identities. | 421 | /// Whether `image` holds every page in `revisions` as the base with `sealed` has it. |
| 304 | pub fn rebase_page_creation_conflict( | 422 | fn holds( |
| 305 | &self, | 423 | &self, |
| 306 | id: u64, | 424 | image: &[u8], |
| 307 | local: &[u8], | 425 | sealed: Option<&Transaction>, |
| 308 | remote: &[u8], | 426 | revisions: &BTreeMap<ExGuid, ExGuid>, |
| 309 | before: Option<ExGuid>, | 427 | ) -> Result<bool> { |
| 310 | ) -> Result<()> { | 428 | let base = base::read(&*self.lock()?, base::Image::Base)?.ok_or_else(|| { |
| 311 | self.resolve_conflict(id, local, remote, |intent| { | 429 | io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image") |
| 312 | let Operation::CreatePage(page) = intent.operation else { | 430 | })?; |
| 313 | return Err(io::Error::new( | 431 | let (local, remote) = (onestore::Arena::default(), onestore::Arena::default()); |
| 314 | io::ErrorKind::InvalidInput, | 432 | let mut local = onestore::Section::open(&local, base)?; |
| 315 | "Select a page-creation conflict", | 433 | if let Some(sealed) = sealed { |
| 316 | ) | 434 | local.replay(sealed)?; |
| 317 | .into()); | 435 | } |
| 318 | }; | 436 | let remote = onestore::Section::open(&remote, image.to_vec())?; |
| 319 | let page = page.reposition(before)?; | 437 | Ok(revisions.keys().all( |
| 320 | PreparedEdit::create_page(remote, &page)?; | 438 | |space| matches!((local.page(*space), remote.page(*space)), (Ok(a), Ok(b)) if a == b), |
| 321 | Ok(Operation::CreatePage(page)) | 439 | )) |
| 322 | }) | ||
| 323 | } | 440 | } |
| 324 | 441 | ||
| 325 | /// Reviews a page batch against both cache images, retaining its page and series identities. | 442 | /// Asks the section thread to replay the queue on `image`; `Err` is the conflict found. |
| 326 | pub fn rebase_pages_conflict( | 443 | fn rebase( |
| 327 | &self, | 444 | &self, |
| 328 | id: u64, | 445 | image: Option<Vec<u8>>, |
| 329 | local: &[u8], | 446 | resolve: Option<Resolve>, |
| 330 | remote: &[u8], | 447 | ) -> Result<std::result::Result<Vec<ExGuid>, Conflict>> { |
| 331 | edits: &[onestore::PageEdit], | 448 | Ok( |
| 332 | ) -> Result<()> { | 449 | match self.ask(|reply| Request::Rebase { |
| 333 | self.resolve_conflict(id, local, remote, |intent| { | 450 | image, |
| 334 | let Operation::Pages(batch) = intent.operation else { | 451 | resolve, |
| 335 | return Err(io::Error::new( | 452 | reply, |
| 336 | io::ErrorKind::InvalidInput, | 453 | })? { |
| 337 | "Select a page movement or indentation conflict", | 454 | Rebased::Applied { changed } => Ok(changed), |
| 338 | ) | 455 | Rebased::Conflict { id, space, kind } => Err(Conflict { id, space, kind }), |
| 339 | .into()); | 456 | }, |
| 340 | }; | 457 | ) |
| 341 | Ok(Operation::Pages(batch.review( | ||
| 342 | remote, | ||
| 343 | intent.space, | ||
| 344 | edits, | ||
| 345 | )?)) | ||
| 346 | }) | ||
| 347 | } | 458 | } |
| 348 | 459 | ||
| 349 | /// Replaces a conflicting page save with a model reviewed against the remote page. | 460 | fn receipt(&self, id: u64) -> Result<ExGuid> { |
| 350 | /// Both supplied images must match `snapshot` and `remote_snapshot`; the reviewed | 461 | match status(&*self.lock()?, id)? { |
| 351 | /// model must publish against the remote image, and its author is retained. | 462 | Some(EditStatus::Published { revision }) => Ok(revision), |
| 352 | pub fn review_page(&self, id: u64, local: &[u8], remote: &[u8], after: &Page) -> Result<()> { | 463 | _ => Err(io::Error::other("A published edit has no receipt").into()), |
| 353 | self.resolve_conflict(id, local, remote, |intent| { | 464 | } |
| 354 | let Operation::Page(edit) = intent.operation else { | ||
| 355 | return Err(io::Error::new( | ||
| 356 | io::ErrorKind::InvalidInput, | ||
| 357 | "Select a page save conflict", | ||
| 358 | ) | ||
| 359 | .into()); | ||
| 360 | }; | ||
| 361 | let before = page_of(remote, intent.space)?.ok_or_else(|| { | ||
| 362 | io::Error::new( | ||
| 363 | io::ErrorKind::InvalidInput, | ||
| 364 | "The page is no longer in the remote section", | ||
| 365 | ) | ||
| 366 | })?; | ||
| 367 | PreparedEdit::page(remote, intent.space, after, &edit.author)?; | ||
| 368 | Ok(Operation::Page(PageIntent { | ||
| 369 | before, | ||
| 370 | after: after.clone(), | ||
| 371 | author: edit.author, | ||
| 372 | })) | ||
| 373 | }) | ||
| 374 | } | 465 | } |
| 375 | 466 | ||
| 376 | /// Retires the oldest edit's uncertain attempt after review. The branch is first | 467 | /// Records a published batch: the base becomes the image it left, each of its edits |
| 377 | /// exported to `archive` (a new file), which is the record: no receipt is written. | 468 | /// gets a receipt naming the revisions its seal stored, or `revisions`, and it leaves |
| 378 | /// `Some(after)` continues from the reviewed page as a fresh intent against the | 469 | /// the queue. |
| 379 | /// current remote page, keeping the edit's id and its dependents; `None` abandons | 470 | fn acknowledge( |
| 380 | /// the whole local branch, whose edits become `Archived`, and the working image | ||
| 381 | /// returns to the remote image. Both need the reviewed images to be current. | ||
| 382 | pub fn release_attempt( | ||
| 383 | &self, | 471 | &self, |
| 384 | id: u64, | 472 | batch: i64, |
| 385 | local: &[u8], | 473 | transaction: Option<&Transaction>, |
| 386 | remote: &[u8], | 474 | revisions: Option<&BTreeMap<ExGuid, ExGuid>>, |
| 387 | archive: &Path, | ||
| 388 | after: Option<&Page>, | ||
| 389 | ) -> Result<()> { | 475 | ) -> Result<()> { |
| 476 | let mut connection = self.lock()?; | ||
| 477 | let database = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 478 | if let Some(transaction) = transaction { | ||
| 479 | base::publish(&database, transaction)?; | ||
| 480 | } | ||
| 481 | let revisions = match revisions { | ||
| 482 | Some(revisions) => revisions.clone(), | ||
| 483 | None => decode_revisions(&database.query_row( | ||
| 484 | "SELECT revisions FROM batches WHERE id=?1", | ||
| 485 | [batch], | ||
| 486 | |row| row.get::<_, String>(0), | ||
| 487 | )?)?, | ||
| 488 | }; | ||
| 489 | for queued in queue::load(&database, Some(batch))? { | ||
| 490 | database.execute( | ||
| 491 | "INSERT INTO receipts(edit_id, revision) VALUES (?1, ?2)", | ||
| 492 | params![ | ||
| 493 | signed(queued.id)?, | ||
| 494 | revision_of(&queued.edit, &revisions)?.to_string() | ||
| 495 | ], | ||
| 496 | )?; | ||
| 497 | } | ||
| 498 | database.execute("DELETE FROM batches WHERE id=?1", [batch])?; | ||
| 499 | queue::collect(&database)?; | ||
| 500 | database.commit()?; | ||
| 501 | Ok(()) | ||
| 502 | } | ||
| 503 | |||
| 504 | /// Resolves the conflict holding edit `id`: the batch and everything queued after it | ||
| 505 | /// drop their ops on the conflicted page; `Mine` rewrites the remote page to the local | ||
| 506 | /// one first. A conflict on another page may follow. | ||
| 507 | pub fn resolve(&self, id: u64, resolution: Resolution) -> Result<()> { | ||
| 508 | self.resolve_with(id, resolution, None) | ||
| 509 | } | ||
| 510 | |||
| 511 | pub(crate) fn resolve_with(&self, id: u64, keep: Resolution, page: Option<Page>) -> Result<()> { | ||
| 390 | let owner = self.sync_owner()?; | 512 | let owner = self.sync_owner()?; |
| 391 | let intent = self | 513 | let (first, space) = { |
| 392 | .pending()? | 514 | let connection = self.lock()?; |
| 393 | .into_iter() | 515 | let row: Option<(i64, String)> = connection |
| 394 | .next() | 516 | .query_row( |
| 395 | .filter(|intent| intent.id == id) | 517 | "SELECT (SELECT min(id) FROM edits WHERE batch=batches.id), space FROM batches |
| 396 | .ok_or_else(|| { | 518 | WHERE conflict IS NOT NULL AND id=(SELECT batch FROM edits WHERE id=?1)", |
| 397 | io::Error::new( | 519 | [signed(id)?], |
| 520 | |row| Ok((row.get(0)?, row.get(1)?)), | ||
| 521 | ) | ||
| 522 | .optional()?; | ||
| 523 | let Some((first, space)) = row else { | ||
| 524 | return Err(io::Error::new( | ||
| 398 | io::ErrorKind::InvalidInput, | 525 | io::ErrorKind::InvalidInput, |
| 399 | "Only the oldest edit can hold an attempt", | 526 | "The edit is not in conflict", |
| 400 | ) | 527 | ) |
| 401 | })?; | 528 | .into()); |
| 402 | let continued = match after { | 529 | }; |
| 403 | Some(after) => { | 530 | (unsigned(first)?, space.parse()?) |
| 404 | let Operation::Page(edit) = intent.operation else { | ||
| 405 | return Err(io::Error::new( | ||
| 406 | io::ErrorKind::InvalidInput, | ||
| 407 | "Select a page save to continue from", | ||
| 408 | ) | ||
| 409 | .into()); | ||
| 410 | }; | ||
| 411 | let before = page_of(remote, intent.space)?.ok_or_else(|| { | ||
| 412 | io::Error::new( | ||
| 413 | io::ErrorKind::InvalidInput, | ||
| 414 | "The page is no longer in the remote section", | ||
| 415 | ) | ||
| 416 | })?; | ||
| 417 | PreparedEdit::page(remote, intent.space, after, &edit.author)?; | ||
| 418 | Some(Operation::Page(PageIntent { | ||
| 419 | before, | ||
| 420 | after: after.clone(), | ||
| 421 | author: edit.author, | ||
| 422 | })) | ||
| 423 | } | ||
| 424 | None => None, | ||
| 425 | }; | 531 | }; |
| 426 | let mut connection = self | 532 | // A conflict on another page is recorded like any other. |
| 427 | .connection | 533 | let _ = self.rebase( |
| 428 | .lock() | 534 | None, |
| 429 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | 535 | Some(Resolve { |
| 430 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | 536 | first, |
| 431 | let (base, working) = images::both(&transaction)?; | 537 | space, |
| 432 | if working != local || base != remote { | 538 | keep, |
| 433 | return Err(io::Error::new( | 539 | page, |
| 434 | io::ErrorKind::ResourceBusy, | 540 | }), |
| 435 | "The reviewed cache images changed", | ||
| 436 | ) | ||
| 437 | .into()); | ||
| 438 | } | ||
| 439 | let id = i64::try_from(id).map_err(io::Error::other)?; | ||
| 440 | let attempted: bool = transaction.query_row( | ||
| 441 | "SELECT EXISTS(SELECT 1 FROM attempt WHERE edit_id=?1)", | ||
| 442 | [id], | ||
| 443 | |row| row.get(0), | ||
| 444 | )?; | 541 | )?; |
| 445 | if !attempted { | ||
| 446 | return Err(io::Error::new( | ||
| 447 | io::ErrorKind::InvalidInput, | ||
| 448 | "The edit has no uncertain attempt", | ||
| 449 | ) | ||
| 450 | .into()); | ||
| 451 | } | ||
| 452 | drop(transaction); | ||
| 453 | drop(connection); | ||
| 454 | self.export_recovery(archive)?; | ||
| 455 | let mut connection = self | ||
| 456 | .connection | ||
| 457 | .lock() | ||
| 458 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 459 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 460 | transaction.execute("DELETE FROM attempt WHERE edit_id=?1", [id])?; | ||
| 461 | match continued { | ||
| 462 | Some(operation) => { | ||
| 463 | transaction.execute( | ||
| 464 | "UPDATE edits SET operation=?1 WHERE id=?2", | ||
| 465 | params![ | ||
| 466 | serde_json::to_string(&operation).map_err(io::Error::other)?, | ||
| 467 | id | ||
| 468 | ], | ||
| 469 | )?; | ||
| 470 | } | ||
| 471 | None => { | ||
| 472 | transaction.execute( | ||
| 473 | "INSERT INTO archived(edit_id, archive) SELECT id, ?1 FROM edits", | ||
| 474 | [archive.to_string_lossy().into_owned()], | ||
| 475 | )?; | ||
| 476 | transaction.execute("DELETE FROM edits", [])?; | ||
| 477 | transaction.execute("UPDATE replica SET working=x'' WHERE id=1", [])?; | ||
| 478 | } | ||
| 479 | } | ||
| 480 | transaction.commit()?; | ||
| 481 | drop(connection); | ||
| 482 | drop(owner); | 542 | drop(owner); |
| 483 | self.wake_sync(); | 543 | self.wake_sync(); |
| 484 | Ok(()) | 544 | Ok(()) |
| 485 | } | 545 | } |
| 486 | 546 | ||
| 487 | fn resolve_conflict( | 547 | /// Retires the uncertain attempt of the batch holding edit `id` after review. The |
| 488 | &self, | 548 | /// queue is first exported to `archive` (a new file), which is the record: no receipt |
| 489 | id: u64, | 549 | /// is written. `Mine` publishes the batch again against the current remote; `Theirs` |
| 490 | local: &[u8], | 550 | /// abandons every unpublished edit, which become `Archived`, and the local pages return |
| 491 | remote: &[u8], | 551 | /// to the remote's. |
| 492 | update: impl FnOnce(PendingEdit) -> Result<Operation>, | 552 | pub fn release(&self, id: u64, archive: &Path, resolution: Resolution) -> Result<()> { |
| 493 | ) -> Result<()> { | ||
| 494 | let owner = self.sync_owner()?; | 553 | let owner = self.sync_owner()?; |
| 495 | let intent = self | 554 | let batch: Option<i64> = self |
| 496 | .pending()? | 555 | .lock()? |
| 497 | .into_iter() | 556 | .query_row( |
| 498 | .next() | 557 | "SELECT id FROM batches WHERE attempted=1 AND id=(SELECT batch FROM edits WHERE id=?1)", |
| 499 | .filter(|intent| intent.id == id) | 558 | [signed(id)?], |
| 500 | .ok_or_else(|| { | 559 | |row| row.get(0), |
| 501 | io::Error::new( | ||
| 502 | io::ErrorKind::InvalidInput, | ||
| 503 | "Only the oldest conflict can be rebased", | ||
| 504 | ) | ||
| 505 | })?; | ||
| 506 | let operation = update(intent)?; | ||
| 507 | let mut connection = self | ||
| 508 | .connection | ||
| 509 | .lock() | ||
| 510 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 511 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 512 | let (base, working) = images::both(&transaction)?; | ||
| 513 | if working != local || base != remote { | ||
| 514 | return Err(io::Error::new( | ||
| 515 | io::ErrorKind::ResourceBusy, | ||
| 516 | "The reviewed cache images changed", | ||
| 517 | ) | 560 | ) |
| 518 | .into()); | 561 | .optional()?; |
| 519 | } | 562 | let Some(batch) = batch else { |
| 520 | let id = i64::try_from(id).map_err(io::Error::other)?; | ||
| 521 | let eligible: bool = transaction.query_row( | ||
| 522 | "SELECT EXISTS(SELECT 1 FROM conflicts WHERE edit_id=?1) AND NOT EXISTS(SELECT 1 FROM attempt)", | ||
| 523 | [id], |row| row.get(0), | ||
| 524 | )?; | ||
| 525 | if !eligible { | ||
| 526 | return Err(io::Error::new( | 563 | return Err(io::Error::new( |
| 527 | io::ErrorKind::InvalidInput, | 564 | io::ErrorKind::InvalidInput, |
| 528 | "The edit is not an unattempted conflict", | 565 | "The edit has no uncertain attempt", |
| 529 | ) | 566 | ) |
| 530 | .into()); | 567 | .into()); |
| 568 | }; | ||
| 569 | self.export_recovery(archive)?; | ||
| 570 | { | ||
| 571 | let mut connection = self.lock()?; | ||
| 572 | let transaction = | ||
| 573 | connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 574 | match resolution { | ||
| 575 | Resolution::Mine => { | ||
| 576 | transaction.execute("UPDATE batches SET attempted=0 WHERE id=?1", [batch])?; | ||
| 577 | } | ||
| 578 | Resolution::Theirs => { | ||
| 579 | transaction.execute( | ||
| 580 | "INSERT INTO archived(edit_id, archive) SELECT id, ?1 FROM edits", | ||
| 581 | [archive.to_string_lossy().into_owned()], | ||
| 582 | )?; | ||
| 583 | transaction.execute("DELETE FROM batches", [])?; | ||
| 584 | if let Some(remote) = base::read(&transaction, base::Image::Remote)? { | ||
| 585 | base::write(&transaction, base::Image::Base, &remote)?; | ||
| 586 | base::clear(&transaction, base::Image::Remote)?; | ||
| 587 | } | ||
| 588 | queue::collect(&transaction)?; | ||
| 589 | } | ||
| 590 | } | ||
| 591 | transaction.commit()?; | ||
| 592 | } | ||
| 593 | if resolution == Resolution::Theirs { | ||
| 594 | self.ask(|reply| Request::Reopen { reply })?; | ||
| 531 | } | 595 | } |
| 532 | transaction.execute( | ||
| 533 | "UPDATE edits SET operation=?1 WHERE id=?2", | ||
| 534 | params![ | ||
| 535 | serde_json::to_string(&operation).map_err(io::Error::other)?, | ||
| 536 | id | ||
| 537 | ], | ||
| 538 | )?; | ||
| 539 | transaction.execute("DELETE FROM conflicts WHERE edit_id=?1", [id])?; | ||
| 540 | transaction.commit()?; | ||
| 541 | drop(connection); | ||
| 542 | drop(owner); | 596 | drop(owner); |
| 543 | self.wake_sync(); | 597 | self.wake_sync(); |
| 544 | Ok(()) | 598 | Ok(()) |
| ... | @@ -556,34 +610,25 @@ impl Replica { | ... | @@ -556,34 +610,25 @@ impl Replica { |
| 556 | })?) | 610 | })?) |
| 557 | } | 611 | } |
| 558 | 612 | ||
| 559 | fn acknowledge(&self, id: u64, revision: ExGuid, snapshot: &[u8]) -> Result<()> { | 613 | /// Whether nothing is queued and the remote still has the base's stamp, or the queue |
| 560 | let id = i64::try_from(id).map_err(io::Error::other)?; | 614 | /// is blocked on a remote that has not changed since; reads neither image and does not |
| 561 | let mut connection = self | 615 | /// lock the cache during remote I/O. |
| 562 | .connection | 616 | pub(crate) fn settled(&self, remote: &mut impl Remote) -> Result<bool> { |
| 563 | .lock() | 617 | let state = state(&*self.lock()?)?; |
| 564 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | 618 | let expected = match &state.blocked { |
| 565 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | 619 | Some(_) => state.remote.unwrap_or(state.base), |
| 566 | transaction.execute( | 620 | None if !state.queued => state.base, |
| 567 | "INSERT INTO receipts(edit_id, revision) VALUES (?1, ?2)", | 621 | None => return Ok(false), |
| 568 | params![id, revision.to_string()], | 622 | }; |
| 569 | )?; | 623 | let Some(stamp) = remote.stamp().map_err(Error::RemoteIo)? else { |
| 570 | transaction.execute("DELETE FROM edits WHERE id=?1", [id])?; | 624 | return Ok(false); |
| 571 | let (base, working) = images::both(&transaction)?; | 625 | }; |
| 572 | let pending: bool = | 626 | Ok(stamp == expected) |
| 573 | transaction.query_row("SELECT EXISTS(SELECT 1 FROM edits)", [], |row| row.get(0))?; | ||
| 574 | images::set_base( | ||
| 575 | &transaction, | ||
| 576 | &base, | ||
| 577 | snapshot, | ||
| 578 | if pending { &working } else { snapshot }, | ||
| 579 | )?; | ||
| 580 | transaction.commit()?; | ||
| 581 | Ok(()) | ||
| 582 | } | 627 | } |
| 583 | } | 628 | } |
| 584 | 629 | ||
| 585 | pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStatus>> { | 630 | pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStatus>> { |
| 586 | let id = i64::try_from(id).map_err(io::Error::other)?; | 631 | let id = signed(id)?; |
| 587 | if let Some(revision) = connection | 632 | if let Some(revision) = connection |
| 588 | .query_row( | 633 | .query_row( |
| 589 | "SELECT revision FROM receipts WHERE edit_id=?1", | 634 | "SELECT revision FROM receipts WHERE edit_id=?1", |
| ... | @@ -606,46 +651,26 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat | ... | @@ -606,46 +651,26 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat |
| 606 | { | 651 | { |
| 607 | return Ok(Some(EditStatus::Archived { archive })); | 652 | return Ok(Some(EditStatus::Archived { archive })); |
| 608 | } | 653 | } |
| 609 | let record: Option<(Option<String>, Option<i64>, String)> = connection.query_row( | 654 | let record: Option<(bool, Option<i64>, Option<String>, String)> = connection |
| 610 | "SELECT attempt.revisions, conflicts.kind, edits.space FROM edits LEFT JOIN attempt ON attempt.edit_id=edits.id LEFT JOIN conflicts ON conflicts.edit_id=edits.id WHERE edits.id=?1", [id], |row| Ok((row.get(0)?,row.get(1)?, row.get(2)?))).optional()?; | 655 | .query_row( |
| 656 | "SELECT batches.attempted, batches.conflict, batches.revisions, edits.edit | ||
| 657 | FROM edits JOIN batches ON batches.id=edits.batch WHERE edits.id=?1", | ||
| 658 | [id], | ||
| 659 | |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), | ||
| 660 | ) | ||
| 661 | .optional()?; | ||
| 611 | Ok(match record { | 662 | Ok(match record { |
| 612 | None => None, | 663 | None => None, |
| 613 | Some((Some(revision), _, space)) => Some(EditStatus::AwaitingConfirmation { | 664 | Some((true, _, revisions, edit)) => { |
| 614 | revision: { | 665 | let revisions = decode_revisions(revisions.as_deref().unwrap_or("{}"))?; |
| 615 | let space = space.parse()?; | 666 | let edit: onestore::op::Edit = serde_json::from_str(&edit) |
| 616 | attempted_revisions(&revision, space)?[&space] | 667 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; |
| 617 | }, | 668 | let revision = revision_of(&edit, &revisions)?; |
| 618 | }), | 669 | Some(EditStatus::AwaitingConfirmation { revision }) |
| 619 | Some((None, Some(kind), _)) => Some(EditStatus::Conflict(match kind { | 670 | } |
| 620 | 0 => ConflictKind::TargetUnavailable, | 671 | Some((false, Some(kind), ..)) => { |
| 621 | 1 => ConflictKind::UnsupportedEdit, | 672 | Some(EditStatus::Conflict(ConflictKind::from_stored(kind)?)) |
| 622 | 2 => ConflictKind::StructureChanged, | 673 | } |
| 623 | 3 => ConflictKind::ContentChanged, | 674 | Some((false, None, ..)) => Some(EditStatus::Pending), |
| 624 | _ => { | ||
| 625 | return Err(io::Error::new( | ||
| 626 | io::ErrorKind::InvalidData, | ||
| 627 | "Unknown cached conflict kind", | ||
| 628 | ) | ||
| 629 | .into()); | ||
| 630 | } | ||
| 631 | })), | ||
| 632 | Some((None, None, _)) => Some(EditStatus::Pending), | ||
| 633 | }) | 675 | }) |
| 634 | } | 676 | } |
| 635 | |||
| 636 | fn attempted_revisions(encoded: &str, space: ExGuid) -> Result<BTreeMap<ExGuid, ExGuid>> { | ||
| 637 | let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(encoded) | ||
| 638 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; | ||
| 639 | if !revisions.contains_key(&space) | ||
| 640 | || revisions | ||
| 641 | .iter() | ||
| 642 | .any(|(sid, rid)| sid.guid == [0; 16] || rid.guid == [0; 16]) | ||
| 643 | { | ||
| 644 | return Err(io::Error::new( | ||
| 645 | io::ErrorKind::InvalidData, | ||
| 646 | "Cached publication evidence is incomplete", | ||
| 647 | ) | ||
| 648 | .into()); | ||
| 649 | } | ||
| 650 | Ok(revisions) | ||
| 651 | } |
crates/notebook/src/worker.rs+15-10| ... | @@ -61,7 +61,8 @@ impl Drop for SyncWorker { | ... | @@ -61,7 +61,8 @@ impl Drop for SyncWorker { |
| 61 | impl Replica { | 61 | impl Replica { |
| 62 | /// Starts one worker, reconnecting through `connect` after transport failures. | 62 | /// Starts one worker, reconnecting through `connect` after transport failures. |
| 63 | /// Local edits wake it; `interval` controls idle polling and transport retries. While | 63 | /// Local edits wake it; `interval` controls idle polling and transport retries. While |
| 64 | /// nothing is queued, a remote whose `stamp` holds is not read again. | 64 | /// nothing is queued, or the queue waits on a remote that has not changed since, a |
| 65 | /// remote whose `stamp` holds is not read again. | ||
| 65 | /// Contended operations returning `NotCommitted` also back off by up to one second. | 66 | /// Contended operations returning `NotCommitted` also back off by up to one second. |
| 66 | /// `observe` runs on the worker after each attempt, including connection errors. | 67 | /// `observe` runs on the worker after each attempt, including connection errors. |
| 67 | /// Cache/document errors stop the worker; inspect them through `observe` or `stop`. | 68 | /// Cache/document errors stop the worker; inspect them through `observe` or `stop`. |
| ... | @@ -75,7 +76,7 @@ impl Replica { | ... | @@ -75,7 +76,7 @@ impl Replica { |
| 75 | where | 76 | where |
| 76 | R: Remote + 'static, | 77 | R: Remote + 'static, |
| 77 | F: FnMut() -> io::Result<R> + Send + 'static, | 78 | F: FnMut() -> io::Result<R> + Send + 'static, |
| 78 | O: FnMut(&Result<Option<(u64, EditStatus)>>) + Send + 'static, | 79 | O: FnMut(&Result<Synced>) + Send + 'static, |
| 79 | { | 80 | { |
| 80 | if interval.is_zero() || Instant::now().checked_add(interval).is_none() { | 81 | if interval.is_zero() || Instant::now().checked_add(interval).is_none() { |
| 81 | return Err(io::Error::new( | 82 | return Err(io::Error::new( |
| ... | @@ -103,18 +104,19 @@ impl Replica { | ... | @@ -103,18 +104,19 @@ impl Replica { |
| 103 | let mut remote: Option<R> = None; | 104 | let mut remote: Option<R> = None; |
| 104 | let jitter = RandomState::new(); | 105 | let jitter = RandomState::new(); |
| 105 | let mut contention = 0_u32; | 106 | let mut contention = 0_u32; |
| 106 | // The first attempt always runs, so `observe` hears once that the remote is | 107 | // The first step, and the first after a failure, always runs, so `observe` |
| 107 | // reachable. | 108 | // hears that the remote is reachable and what state the queue is in. |
| 108 | let mut attempted = false; | 109 | let mut reported = false; |
| 109 | while !worker_signal.stopped.load(Ordering::Acquire) { | 110 | while !worker_signal.stopped.load(Ordering::Acquire) { |
| 110 | let result = match remote.as_mut() { | 111 | let result = match remote.as_mut() { |
| 111 | Some(remote) => { | 112 | Some(remote) => { |
| 112 | if attempted && replica.settled(remote).unwrap_or(false) { | 113 | if reported && replica.settled(remote).unwrap_or(false) { |
| 113 | let _ = receiver.recv_timeout(interval); | 114 | let _ = receiver.recv_timeout(interval); |
| 114 | continue; | 115 | continue; |
| 115 | } | 116 | } |
| 116 | attempted = true; | 117 | let result = replica.sync_once(remote); |
| 117 | replica.sync_once(remote) | 118 | reported = result.is_ok(); |
| 119 | result | ||
| 118 | } | 120 | } |
| 119 | None => match connect() { | 121 | None => match connect() { |
| 120 | Ok(connected) => { | 122 | Ok(connected) => { |
| ... | @@ -126,11 +128,14 @@ impl Replica { | ... | @@ -126,11 +128,14 @@ impl Replica { |
| 126 | }; | 128 | }; |
| 127 | observe(&result); | 129 | observe(&result); |
| 128 | match result { | 130 | match result { |
| 129 | Ok(Some((_, EditStatus::Published { .. }))) => { | 131 | Ok(Synced { |
| 132 | edit: Some((_, EditStatus::Published { .. })), | ||
| 133 | .. | ||
| 134 | }) => { | ||
| 130 | contention = 0; | 135 | contention = 0; |
| 131 | continue; | 136 | continue; |
| 132 | } | 137 | } |
| 133 | Ok(None) => contention = 0, | 138 | Ok(Synced { edit: None, .. }) => contention = 0, |
| 134 | Ok(_) => {} | 139 | Ok(_) => {} |
| 135 | Err(Error::Remote(onestore::CommitError { | 140 | Err(Error::Remote(onestore::CommitError { |
| 136 | state: onestore::CommitState::NotCommitted, | 141 | state: onestore::CommitState::NotCommitted, |
crates/notebook/src/working.rs created+776| ... | @@ -0,0 +1,776 @@ | ||
| 1 | //! The section thread: the only owner of the parsed section, which is the cached base | ||
| 2 | //! image with each sealed batch replayed and the open batch's edits applied. Edits apply | ||
| 3 | //! here and are written in one SQLite transaction per burst; the sync thread asks it to seal | ||
| 4 | //! and, when the remote changed, to rebase the queue. | ||
| 5 | |||
| 6 | use crate::{ | ||
| 7 | ConflictKind, Resolution, Result, base, lock, merge, queue, session::Save, signed, | ||
| 8 | worker::Signal, | ||
| 9 | }; | ||
| 10 | use onestore::{ | ||
| 11 | Arena, ExGuid, Section, Transaction, | ||
| 12 | op::{Edit, Op, OpError}, | ||
| 13 | page::Page, | ||
| 14 | }; | ||
| 15 | use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; | ||
| 16 | use std::{ | ||
| 17 | collections::{BTreeMap, BTreeSet, VecDeque}, | ||
| 18 | io, | ||
| 19 | sync::{Arc, Mutex, Weak, mpsc}, | ||
| 20 | thread, | ||
| 21 | }; | ||
| 22 | |||
| 23 | pub(crate) type Reply<T> = Box<dyn FnOnce(Result<T>) + Send>; | ||
| 24 | |||
| 25 | pub(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. | ||
| 68 | pub(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`). | ||
| 75 | pub(crate) struct Resolve { | ||
| 76 | pub first: u64, | ||
| 77 | pub space: ExGuid, | ||
| 78 | pub keep: Resolution, | ||
| 79 | pub page: Option<Page>, | ||
| 80 | } | ||
| 81 | |||
| 82 | pub(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 | |||
| 93 | impl 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 | |||
| 109 | type Worker = Arc<Mutex<Weak<Signal>>>; | ||
| 110 | |||
| 111 | /// Starts the section thread once the queue opens. | ||
| 112 | pub(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 | |||
| 134 | enum Next { | ||
| 135 | Stop, | ||
| 136 | Reopen, | ||
| 137 | } | ||
| 138 | |||
| 139 | fn 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. | ||
| 174 | struct Accepted { | ||
| 175 | author: String, | ||
| 176 | edit: Edit, | ||
| 177 | done: Done, | ||
| 178 | } | ||
| 179 | |||
| 180 | enum Done { | ||
| 181 | Apply(Reply<u64>), | ||
| 182 | Save(Reply<Option<Save>>), | ||
| 183 | } | ||
| 184 | |||
| 185 | impl 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 | |||
| 194 | struct 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 | |||
| 204 | impl<'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 | |||
| 685 | enum 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. | ||
| 692 | fn 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. | ||
| 711 | fn 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. | ||
| 752 | pub(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. | ||
| 760 | pub(crate) fn sealed(connection: &Connection) -> Result<Option<Sealed>> { | ||
| 761 | let row: Option<(i64, String)> = connection | ||
| 762 | .query_row( | ||
| 763 | "SELECT id, sealed FROM batches WHERE sealed IS NOT NULL ORDER BY id LIMIT 1", | ||
| 764 | [], | ||
| 765 | |row| Ok((row.get(0)?, row.get(1)?)), | ||
| 766 | ) | ||
| 767 | .optional()?; | ||
| 768 | row.map(|(batch, sealed)| { | ||
| 769 | Ok(Sealed { | ||
| 770 | batch, | ||
| 771 | transaction: serde_json::from_str(&sealed) | ||
| 772 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?, | ||
| 773 | }) | ||
| 774 | }) | ||
| 775 | .transpose() | ||
| 776 | } | ||
crates/notebook/tests/assets.rs+14-11| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | use notebook::{EditStatus, Error, Operation, Recovery, Replica}; | 1 | use notebook::{EditStatus, Error, Recovery, Replica}; |
| 2 | use onestore::{ | 2 | use onestore::{ |
| 3 | ExGuid, RevisionIndex, Store, | 3 | ExGuid, RevisionIndex, Store, |
| 4 | document::Document, | 4 | document::Document, |
| ... | @@ -13,6 +13,8 @@ use std::{ | ... | @@ -13,6 +13,8 @@ use std::{ |
| 13 | 13 | ||
| 14 | #[path = "support/model_ops.rs"] | 14 | #[path = "support/model_ops.rs"] |
| 15 | mod model_ops; | 15 | mod model_ops; |
| 16 | #[path = "support/server.rs"] | ||
| 17 | mod server; | ||
| 16 | 18 | ||
| 17 | const FIXTURE: &str = "../../corpus/native-external-assets/notebook"; | 19 | const FIXTURE: &str = "../../corpus/native-external-assets/notebook"; |
| 18 | 20 | ||
| ... | @@ -54,10 +56,7 @@ fn queued_cache(root: &Path) -> Replica { | ... | @@ -54,10 +56,7 @@ fn queued_cache(root: &Path) -> Replica { |
| 54 | .unwrap() | 56 | .unwrap() |
| 55 | .unwrap(); | 57 | .unwrap(); |
| 56 | let page = onestore::PageCreation::new(None, Some("Queued page"), "Author").unwrap(); | 58 | let page = onestore::PageCreation::new(None, Some("Queued page"), "Author").unwrap(); |
| 57 | cache | 59 | server::section_op(&cache, onestore::op::SectionOp::Create(page)); |
| 58 | .create_page(&cache.snapshot().unwrap(), &page) | ||
| 59 | .unwrap() | ||
| 60 | .unwrap(); | ||
| 61 | cache | 60 | cache |
| 62 | } | 61 | } |
| 63 | 62 | ||
| ... | @@ -118,7 +117,10 @@ fn downloaded_media_survives_reopen_and_recovery_with_the_queue_intact() { | ... | @@ -118,7 +117,10 @@ fn downloaded_media_survives_reopen_and_recovery_with_the_queue_intact() { |
| 118 | (summary.cached_assets, summary.cached_asset_bytes), | 117 | (summary.cached_assets, summary.cached_asset_bytes), |
| 119 | (2, 1024) | 118 | (2, 1024) |
| 120 | ); | 119 | ); |
| 121 | assert_eq!(cache.snapshot().unwrap(), working); | 120 | assert_eq!( |
| 121 | server::pages(&cache.snapshot().unwrap()), | ||
| 122 | server::pages(&working) | ||
| 123 | ); | ||
| 122 | assert_eq!(cache.remote_snapshot().unwrap(), remote); | 124 | assert_eq!(cache.remote_snapshot().unwrap(), remote); |
| 123 | assert_eq!(cache.pending().unwrap(), pending); | 125 | assert_eq!(cache.pending().unwrap(), pending); |
| 124 | assert_eq!(cache.status(1).unwrap(), uncertain); | 126 | assert_eq!(cache.status(1).unwrap(), uncertain); |
| ... | @@ -234,10 +236,11 @@ fn download_network_wait_does_not_block_local_edits() { | ... | @@ -234,10 +236,11 @@ fn download_network_wait_does_not_block_local_edits() { |
| 234 | release.send(()).unwrap(); | 236 | release.send(()).unwrap(); |
| 235 | assert_eq!(download.join().unwrap(), bytes); | 237 | assert_eq!(download.join().unwrap(), bytes); |
| 236 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | 238 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); |
| 237 | let Operation::Page(intent) = &cache.pending().unwrap()[2].operation else { | 239 | assert!(matches!( |
| 238 | panic!() | 240 | &cache.pending().unwrap()[2].edit.ops[..], |
| 239 | }; | 241 | [onestore::op::Op::Page { op: onestore::op::PageOp::Text { with, .. }, .. }] |
| 240 | assert_eq!(intent.text_change().unwrap().3, "during download "); | 242 | if with == "during download " |
| 243 | )); | ||
| 241 | }); | 244 | }); |
| 242 | } | 245 | } |
| 243 | 246 | ||
| ... | @@ -284,7 +287,7 @@ fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() { | ... | @@ -284,7 +287,7 @@ fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() { |
| 284 | panic!("Unexpected enumeration") | 287 | panic!("Unexpected enumeration") |
| 285 | } | 288 | } |
| 286 | fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> { | 289 | fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> { |
| 287 | assert_eq!(self.0.sync_once(&mut Native).unwrap(), None); | 290 | assert_eq!(self.0.sync_once(&mut Native).unwrap().edit, None); |
| 288 | Ok(payload(1024).1) | 291 | Ok(payload(1024).1) |
| 289 | } | 292 | } |
| 290 | } | 293 | } |
crates/notebook/tests/cache.rs+230-270| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | use notebook::{Error, Operation, Replica}; | 1 | use notebook::{Error, Replica}; |
| 2 | use onestore::{ | 2 | use onestore::{ |
| 3 | ExGuid, RevisionIndex, Store, | 3 | ExGuid, RevisionIndex, Store, |
| 4 | document::{Document, Kind}, | 4 | document::{Document, Kind}, |
| 5 | op::{Edit, Op, PageOp, SectionOp}, | ||
| 5 | page::{Outline, PageObject}, | 6 | page::{Outline, PageObject}, |
| 6 | }; | 7 | }; |
| 7 | use std::{ | 8 | use std::{ |
| ... | @@ -14,6 +15,8 @@ use std::{ | ... | @@ -14,6 +15,8 @@ use std::{ |
| 14 | 15 | ||
| 15 | #[path = "support/model_ops.rs"] | 16 | #[path = "support/model_ops.rs"] |
| 16 | mod model_ops; | 17 | mod model_ops; |
| 18 | #[path = "support/server.rs"] | ||
| 19 | mod server; | ||
| 17 | 20 | ||
| 18 | fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { | 21 | fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { |
| 19 | let store = Store::parse(source).unwrap(); | 22 | let store = Store::parse(source).unwrap(); |
| ... | @@ -36,25 +39,23 @@ fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { | ... | @@ -36,25 +39,23 @@ fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { |
| 36 | .unwrap() | 39 | .unwrap() |
| 37 | } | 40 | } |
| 38 | 41 | ||
| 39 | fn text_of(page: &onestore::page::Page, text: ExGuid) -> String { | 42 | /// An edit typing `with` at the start of a text. |
| 40 | model_ops::paragraph_with(page, text) | 43 | fn typed(space: ExGuid, text: ExGuid, with: &str) -> Edit { |
| 41 | .unwrap() | 44 | Edit { |
| 42 | .text() | 45 | at: model_ops::now(), |
| 43 | .unwrap() | 46 | ops: vec![Op::Page { |
| 44 | .text | 47 | space, |
| 45 | .text() | 48 | op: PageOp::Text { |
| 46 | .to_owned() | 49 | text, |
| 47 | } | 50 | range: 0..0, |
| 48 | 51 | with: with.into(), | |
| 49 | fn intent(replica: &Replica, at: usize) -> notebook::PageIntent { | 52 | }, |
| 50 | match &replica.pending().unwrap()[at].operation { | 53 | }], |
| 51 | Operation::Page(intent) => intent.clone(), | ||
| 52 | other => panic!("{other:?}"), | ||
| 53 | } | 54 | } |
| 54 | } | 55 | } |
| 55 | 56 | ||
| 56 | #[test] | 57 | #[test] |
| 57 | fn cache_reopen_preserves_exact_images_and_intents() { | 58 | fn cache_reopen_preserves_the_base_and_queued_edits() { |
| 58 | let dir = tempfile::tempdir().unwrap(); | 59 | let dir = tempfile::tempdir().unwrap(); |
| 59 | let path = dir.path().join("section.sqlite"); | 60 | let path = dir.path().join("section.sqlite"); |
| 60 | let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap(); | 61 | let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap(); |
| ... | @@ -72,77 +73,102 @@ fn cache_reopen_preserves_exact_images_and_intents() { | ... | @@ -72,77 +73,102 @@ fn cache_reopen_preserves_exact_images_and_intents() { |
| 72 | }) | 73 | }) |
| 73 | .unwrap() | 74 | .unwrap() |
| 74 | .unwrap(); | 75 | .unwrap(); |
| 75 | let edited = replica.snapshot().unwrap(); | 76 | assert_eq!(target(&replica.snapshot().unwrap()).2, "café 🐈 日本語"); |
| 76 | assert_eq!(target(&edited).2, "café 🐈 日本語"); | 77 | let edits = replica.pending().unwrap(); |
| 77 | let intents = replica.pending().unwrap(); | 78 | assert_eq!(edits.len(), 1); |
| 78 | assert_eq!(intents.len(), 1); | ||
| 79 | assert_eq!((intents[0].id, intents[0].space), (first, sid)); | ||
| 80 | let first_intent = intent(&replica, 0); | ||
| 81 | assert_eq!( | 79 | assert_eq!( |
| 82 | first_intent.text_change(), | 80 | (edits[0].id, edits[0].author.as_str()), |
| 83 | Some((oid, "café 🦀".into(), 5..7, "🐈 日本語".into())) | 81 | (first, model_ops::AUTHOR) |
| 84 | ); | 82 | ); |
| 85 | assert_eq!(first_intent.author, model_ops::AUTHOR); | 83 | assert!(matches!( |
| 84 | &edits[0].edit.ops[..], | ||
| 85 | [Op::Page { space, op: PageOp::Text { text, range, with } }] | ||
| 86 | if (*space, *text, range.clone(), with.as_str()) == (sid, oid, 5..7, "🐈 日本語") | ||
| 87 | )); | ||
| 88 | let beside = |suffix: &str| { | ||
| 89 | let mut file = path.as_os_str().to_owned(); | ||
| 90 | file.push(suffix); | ||
| 91 | std::path::PathBuf::from(file) | ||
| 92 | }; | ||
| 93 | // Commits go to the write-ahead log; its index stays in memory. | ||
| 94 | assert!(beside("-wal").exists()); | ||
| 95 | assert!(!beside("-shm").exists()); | ||
| 86 | drop(replica); | 96 | drop(replica); |
| 97 | assert!( | ||
| 98 | !beside("-wal").exists(), | ||
| 99 | "closing checkpoints and removes the log" | ||
| 100 | ); | ||
| 87 | let replica = Replica::open(&path).unwrap(); | 101 | let replica = Replica::open(&path).unwrap(); |
| 88 | assert_eq!(replica.snapshot().unwrap(), edited); | 102 | assert_eq!(target(&replica.snapshot().unwrap()).2, "café 🐈 日本語"); |
| 89 | assert_eq!(replica.pending().unwrap(), intents); | 103 | assert_eq!(replica.pending().unwrap(), edits); |
| 90 | let second = model_ops::save(&replica, oid, |page| { | 104 | let second = replica |
| 91 | model_ops::replace_text(page, oid, 0..0, "Recovered ") | 105 | .apply(model_ops::AUTHOR, typed(sid, oid, "Recovered ")) |
| 92 | }) | 106 | .unwrap(); |
| 93 | .unwrap() | 107 | assert!(second > first); |
| 94 | .unwrap(); | ||
| 95 | assert_eq!(second, first, "an unattempted save is replaced in place"); | ||
| 96 | let coalesced = intent(&replica, 0); | ||
| 97 | assert_eq!(coalesced.before, first_intent.before); | ||
| 98 | assert_eq!(text_of(&coalesced.after, oid), "Recovered café 🐈 日本語"); | ||
| 99 | assert_eq!( | 108 | assert_eq!( |
| 100 | target(&replica.snapshot().unwrap()).2, | 109 | target(&replica.snapshot().unwrap()).2, |
| 101 | "Recovered café 🐈 日本語" | 110 | "Recovered café 🐈 日本語" |
| 102 | ); | 111 | ); |
| 112 | assert_eq!(replica.pending().unwrap().len(), 2); | ||
| 103 | } | 113 | } |
| 104 | 114 | ||
| 105 | #[test] | 115 | #[test] |
| 106 | fn failed_saves_preserve_both_intent_queue_and_working_image() { | 116 | fn refused_edits_and_failed_writes_leave_the_queue_as_it_was() { |
| 107 | let dir = tempfile::tempdir().unwrap(); | 117 | let dir = tempfile::tempdir().unwrap(); |
| 108 | let path = dir.path().join("section.sqlite"); | 118 | let path = dir.path().join("section.sqlite"); |
| 109 | let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap(); | 119 | let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap(); |
| 110 | let replica = Replica::create(&path, &source).unwrap(); | 120 | let replica = Replica::create(&path, &source).unwrap(); |
| 111 | let (sid, oid, _) = target(&source); | 121 | let (sid, oid, _) = target(&source); |
| 112 | let (_, mut page) = model_ops::locate(&source, oid); | 122 | let empty = Outline { |
| 113 | let mut empty = Outline { | ||
| 114 | id: onestore::page::text::new_id().unwrap(), | 123 | id: onestore::page::text::new_id().unwrap(), |
| 115 | title: false, | 124 | title: false, |
| 116 | min_width: None, | 125 | min_width: None, |
| 117 | layout: Default::default(), | 126 | layout: onestore::document::Layout { |
| 127 | x: Some(72.0), | ||
| 128 | y: Some(400.0), | ||
| 129 | ..Default::default() | ||
| 130 | }, | ||
| 118 | indents: Vec::new(), | 131 | indents: Vec::new(), |
| 119 | paragraphs: Vec::new(), | 132 | paragraphs: Vec::new(), |
| 120 | unsupported: Vec::new(), | 133 | unsupported: Vec::new(), |
| 121 | }; | 134 | }; |
| 122 | empty.layout.x = Some(72.0); | 135 | let refused = Edit { |
| 123 | empty.layout.y = Some(400.0); | 136 | at: model_ops::now(), |
| 124 | page.objects.push(PageObject::Outline(empty)); | 137 | ops: vec![ |
| 125 | assert!(replica.save(&source, sid, &page, "Author").is_err()); | 138 | Op::Page { |
| 126 | let (_, mut page) = model_ops::locate(&source, oid); | 139 | space: sid, |
| 127 | model_ops::replace_text(&mut page, oid, 0..0, "Elsewhere "); | 140 | op: PageOp::Text { |
| 128 | let foreign = ExGuid::default(); | 141 | text: oid, |
| 129 | assert!(replica.save(&source, foreign, &page, "Author").is_err()); | 142 | range: 0..0, |
| 143 | with: "Undone ".into(), | ||
| 144 | }, | ||
| 145 | }, | ||
| 146 | Op::Page { | ||
| 147 | space: sid, | ||
| 148 | op: PageOp::Add { | ||
| 149 | object: PageObject::Outline(empty), | ||
| 150 | before: None, | ||
| 151 | }, | ||
| 152 | }, | ||
| 153 | ], | ||
| 154 | }; | ||
| 155 | assert!(matches!( | ||
| 156 | replica.apply("Author", refused), | ||
| 157 | Err(Error::Rejected(_)) | ||
| 158 | )); | ||
| 159 | assert!(matches!( | ||
| 160 | replica.apply("Author", typed(ExGuid::default(), oid, "Elsewhere ")), | ||
| 161 | Err(Error::Rejected(_)) | ||
| 162 | )); | ||
| 130 | assert_eq!(replica.snapshot().unwrap(), source); | 163 | assert_eq!(replica.snapshot().unwrap(), source); |
| 131 | assert!(replica.pending().unwrap().is_empty()); | 164 | assert!(replica.pending().unwrap().is_empty()); |
| 132 | drop(replica); | 165 | drop(replica); |
| 133 | let connection = rusqlite::Connection::open(&path).unwrap(); | 166 | let connection = rusqlite::Connection::open(&path).unwrap(); |
| 134 | connection.execute_batch("CREATE TRIGGER fail_image BEFORE UPDATE ON replica BEGIN SELECT RAISE(ABORT, 'Injected image update failure'); END;").unwrap(); | 167 | connection.execute_batch("CREATE TRIGGER fail_edit BEFORE INSERT ON edits BEGIN SELECT RAISE(ABORT, 'Injected queue failure'); END;").unwrap(); |
| 135 | drop(connection); | 168 | drop(connection); |
| 136 | let replica = Replica::open(&path).unwrap(); | 169 | let replica = Replica::open(&path).unwrap(); |
| 137 | assert!(matches!( | 170 | assert!(replica.apply("Author", typed(sid, oid, "lost? ")).is_err()); |
| 138 | model_ops::save(&replica, oid, |page| model_ops::replace_text( | 171 | assert_eq!(replica.snapshot().unwrap(), source); |
| 139 | page, | ||
| 140 | oid, | ||
| 141 | 0..0, | ||
| 142 | "lost? " | ||
| 143 | )), | ||
| 144 | Err(Error::Database(_)) | ||
| 145 | )); | ||
| 146 | drop(replica); | 172 | drop(replica); |
| 147 | let replica = Replica::open(&path).unwrap(); | 173 | let replica = Replica::open(&path).unwrap(); |
| 148 | assert_eq!(replica.snapshot().unwrap(), source); | 174 | assert_eq!(replica.snapshot().unwrap(), source); |
| ... | @@ -155,7 +181,7 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { | ... | @@ -155,7 +181,7 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { |
| 155 | 181 | ||
| 156 | let directory = tempfile::tempdir().unwrap(); | 182 | let directory = tempfile::tempdir().unwrap(); |
| 157 | let source = onestore::create_section("recovery.one", "base", "Fixture").unwrap(); | 183 | let source = onestore::create_section("recovery.one", "base", "Fixture").unwrap(); |
| 158 | let (_, object, _) = target(&source); | 184 | let (space, object, _) = target(&source); |
| 159 | let replica = Replica::create(directory.path().join("live.sqlite"), &source).unwrap(); | 185 | let replica = Replica::create(directory.path().join("live.sqlite"), &source).unwrap(); |
| 160 | let start = Barrier::new(4); | 186 | let start = Barrier::new(4); |
| 161 | std::thread::scope(|scope| { | 187 | std::thread::scope(|scope| { |
| ... | @@ -164,18 +190,12 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { | ... | @@ -164,18 +190,12 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { |
| 164 | scope.spawn(move || { | 190 | scope.spawn(move || { |
| 165 | start.wait(); | 191 | start.wait(); |
| 166 | for edit in 0..20 { | 192 | for edit in 0..20 { |
| 167 | loop { | 193 | replica |
| 168 | let marker = format!("[{writer}:{edit}] "); | 194 | .apply( |
| 169 | match model_ops::save(replica, object, |page| { | 195 | "Fixture", |
| 170 | model_ops::replace_text(page, object, 0..0, &marker) | 196 | typed(space, object, &format!("[{writer}:{edit}] ")), |
| 171 | }) { | 197 | ) |
| 172 | Ok(Some(_)) => break, | 198 | .unwrap(); |
| 173 | Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => { | ||
| 174 | continue; | ||
| 175 | } | ||
| 176 | other => panic!("Unexpected local edit: {other:?}"), | ||
| 177 | } | ||
| 178 | } | ||
| 179 | } | 199 | } |
| 180 | }); | 200 | }); |
| 181 | } | 201 | } |
| ... | @@ -185,20 +205,22 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { | ... | @@ -185,20 +205,22 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { |
| 185 | replica.export_recovery(&path).unwrap(); | 205 | replica.export_recovery(&path).unwrap(); |
| 186 | let archive = Recovery::open(path).unwrap(); | 206 | let archive = Recovery::open(path).unwrap(); |
| 187 | let pending = archive.pending().unwrap(); | 207 | let pending = archive.pending().unwrap(); |
| 188 | let snapshot = target(&archive.snapshot().unwrap()).2; | 208 | // Each edit types at the start, so the text is the queue read backwards. |
| 189 | match pending.as_slice() { | 209 | let expected: String = pending |
| 190 | [] => assert_eq!(snapshot, "base"), | 210 | .iter() |
| 191 | [single] => { | 211 | .rev() |
| 192 | let Operation::Page(intent) = &single.operation else { | 212 | .map(|edit| match &edit.edit.ops[..] { |
| 193 | panic!() | 213 | [ |
| 194 | }; | 214 | Op::Page { |
| 195 | let (id, before, range, replacement) = intent.text_change().unwrap(); | 215 | op: PageOp::Text { with, .. }, |
| 196 | assert_eq!((id, before.as_str(), range), (object, "base", 0..0)); | 216 | .. |
| 197 | assert_eq!(snapshot, format!("{replacement}base")); | 217 | }, |
| 198 | assert_eq!(text_of(&intent.after, object), snapshot); | 218 | ] => with.as_str(), |
| 199 | } | 219 | other => panic!("{other:?}"), |
| 200 | more => panic!("saves to one page coalesce: {more:?}"), | 220 | }) |
| 201 | } | 221 | .chain(["base"]) |
| 222 | .collect(); | ||
| 223 | assert_eq!(target(&archive.snapshot().unwrap()).2, expected); | ||
| 202 | assert_eq!(archive.remote_snapshot().unwrap(), source); | 224 | assert_eq!(archive.remote_snapshot().unwrap(), source); |
| 203 | assert_eq!( | 225 | assert_eq!( |
| 204 | archive.summary().unwrap().queued_edits, | 226 | archive.summary().unwrap().queued_edits, |
| ... | @@ -207,7 +229,7 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { | ... | @@ -207,7 +229,7 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { |
| 207 | assert!(archive.receipts().unwrap().is_empty()); | 229 | assert!(archive.receipts().unwrap().is_empty()); |
| 208 | } | 230 | } |
| 209 | }); | 231 | }); |
| 210 | assert_eq!(replica.pending().unwrap().len(), 1); | 232 | assert_eq!(replica.pending().unwrap().len(), 60); |
| 211 | let content = target(&replica.snapshot().unwrap()).2; | 233 | let content = target(&replica.snapshot().unwrap()).2; |
| 212 | for writer in 0..3 { | 234 | for writer in 0..3 { |
| 213 | for edit in 0..20 { | 235 | for edit in 0..20 { |
| ... | @@ -242,9 +264,10 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() { | ... | @@ -242,9 +264,10 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() { |
| 242 | .unwrap(); | 264 | .unwrap(); |
| 243 | for sql in [ | 265 | for sql in [ |
| 244 | "PRAGMA application_id=0".to_owned(), | 266 | "PRAGMA application_id=0".to_owned(), |
| 267 | // Schema 14 converts; older caches do not. | ||
| 245 | format!( | 268 | format!( |
| 246 | "PRAGMA application_id=1330529615; PRAGMA user_version={}", | 269 | "PRAGMA application_id=1330529615; PRAGMA user_version={}", |
| 247 | current - 1 | 270 | current - 2 |
| 248 | ), | 271 | ), |
| 249 | format!("PRAGMA user_version={}", current + 1), | 272 | format!("PRAGMA user_version={}", current + 1), |
| 250 | ] { | 273 | ] { |
| ... | @@ -289,52 +312,26 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() { | ... | @@ -289,52 +312,26 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() { |
| 289 | } | 312 | } |
| 290 | 313 | ||
| 291 | #[test] | 314 | #[test] |
| 292 | fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() { | 315 | fn twelve_local_editors_queue_every_edit_once_in_order() { |
| 293 | let dir = tempfile::tempdir().unwrap(); | 316 | let dir = tempfile::tempdir().unwrap(); |
| 294 | let path = dir.path().join("section.sqlite"); | 317 | let path = dir.path().join("section.sqlite"); |
| 295 | let source = onestore::create_section("section.one", "Shared café 🦀", "Fixture").unwrap(); | 318 | let source = onestore::create_section("section.one", "Shared café 🦀", "Fixture").unwrap(); |
| 296 | let replica = Replica::create(&path, &source).unwrap(); | 319 | let replica = Replica::create(&path, &source).unwrap(); |
| 297 | let (sid, oid, _) = target(&source); | 320 | let (sid, oid, _) = target(&source); |
| 298 | let barrier = Barrier::new(12); | 321 | let barrier = Barrier::new(12); |
| 299 | let deadline = Instant::now() + Duration::from_secs(60); | ||
| 300 | let outcomes = std::thread::scope(|scope| { | 322 | let outcomes = std::thread::scope(|scope| { |
| 301 | let handles: Vec<_> = (0..12) | 323 | let handles: Vec<_> = (0..12) |
| 302 | .map(|writer| { | 324 | .map(|writer| { |
| 303 | let (replica, source, barrier) = (&replica, &source, &barrier); | 325 | let (replica, barrier) = (&replica, &barrier); |
| 304 | scope.spawn(move || { | 326 | scope.spawn(move || { |
| 305 | let (_, mut page) = model_ops::locate(source, oid); | ||
| 306 | model_ops::replace_text(&mut page, oid, 0..0, &format!("[initial-{writer}] ")); | ||
| 307 | barrier.wait(); | 327 | barrier.wait(); |
| 308 | let mut ids = Vec::new(); | 328 | (0..20) |
| 309 | let first_won = match replica.save(source, sid, &page, model_ops::AUTHOR) { | 329 | .map(|edit| { |
| 310 | Ok(Some(id)) => { | 330 | replica |
| 311 | ids.push(id); | 331 | .apply("Fixture", typed(sid, oid, &format!("[{writer}-{edit}] "))) |
| 312 | true | 332 | .unwrap() |
| 313 | } | 333 | }) |
| 314 | Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => false, | 334 | .collect::<Vec<_>>() |
| 315 | other => panic!("Unexpected first edit: {other:?}"), | ||
| 316 | }; | ||
| 317 | for edit in 0..20 { | ||
| 318 | loop { | ||
| 319 | assert!( | ||
| 320 | Instant::now() < deadline, | ||
| 321 | "Writer {writer} stopped progressing at {edit}" | ||
| 322 | ); | ||
| 323 | let marker = format!("[{writer}-{edit}] "); | ||
| 324 | match model_ops::save(replica, oid, |page| { | ||
| 325 | model_ops::replace_text(page, oid, 0..0, &marker) | ||
| 326 | }) { | ||
| 327 | Ok(Some(id)) => { | ||
| 328 | ids.push(id); | ||
| 329 | break; | ||
| 330 | } | ||
| 331 | Err(Error::Io(error)) | ||
| 332 | if error.kind() == ErrorKind::ResourceBusy => {} | ||
| 333 | other => panic!("Unexpected edit: {other:?}"), | ||
| 334 | } | ||
| 335 | } | ||
| 336 | } | ||
| 337 | (first_won, ids) | ||
| 338 | }) | 335 | }) |
| 339 | }) | 336 | }) |
| 340 | .collect(); | 337 | .collect(); |
| ... | @@ -343,31 +340,29 @@ fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() { | ... | @@ -343,31 +340,29 @@ fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() { |
| 343 | .map(|handle| handle.join().unwrap()) | 340 | .map(|handle| handle.join().unwrap()) |
| 344 | .collect::<Vec<_>>() | 341 | .collect::<Vec<_>>() |
| 345 | }); | 342 | }); |
| 346 | assert_eq!(outcomes.iter().filter(|(won, _)| *won).count(), 1); | 343 | for ids in &outcomes { |
| 347 | let ids: BTreeSet<_> = outcomes.into_iter().flat_map(|(_, ids)| ids).collect(); | 344 | assert!( |
| 348 | assert_eq!(ids.len(), 1, "every save replaced the unattempted head"); | 345 | ids.windows(2).all(|pair| pair[0] < pair[1]), |
| 349 | let final_bytes = replica.snapshot().unwrap(); | 346 | "a writer's edits keep its order" |
| 350 | let content = target(&final_bytes).2; | 347 | ); |
| 351 | let pending = replica.pending().unwrap(); | 348 | } |
| 352 | assert_eq!(pending.len(), 1); | 349 | let ids: BTreeSet<_> = outcomes.into_iter().flatten().collect(); |
| 353 | assert_eq!(pending[0].id, *ids.first().unwrap()); | 350 | assert_eq!(ids.len(), 240); |
| 354 | let (id, before, range, replacement) = intent(&replica, 0).text_change().unwrap(); | 351 | let content = target(&replica.snapshot().unwrap()).2; |
| 355 | assert_eq!((id, before.as_str(), range), (oid, "Shared café 🦀", 0..0)); | 352 | assert!(content.ends_with("Shared café 🦀")); |
| 356 | assert_eq!(content, format!("{replacement}Shared café 🦀")); | ||
| 357 | assert_eq!(content.matches("[initial-").count(), 1); | ||
| 358 | for writer in 0..12 { | 353 | for writer in 0..12 { |
| 359 | for edit in 0..20 { | 354 | for edit in 0..20 { |
| 360 | assert_eq!(content.matches(&format!("[{writer}-{edit}] ")).count(), 1); | 355 | assert_eq!(content.matches(&format!("[{writer}-{edit}] ")).count(), 1); |
| 361 | } | 356 | } |
| 362 | } | 357 | } |
| 363 | let (_, mut stale) = model_ops::locate(&source, oid); | 358 | let pending = replica.pending().unwrap(); |
| 364 | model_ops::replace_text(&mut stale, oid, 0..0, "stale "); | 359 | assert_eq!( |
| 365 | assert!( | 360 | pending.iter().map(|edit| edit.id).collect::<BTreeSet<_>>(), |
| 366 | matches!(replica.save(&source, sid, &stale, "Author"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy) | 361 | ids |
| 367 | ); | 362 | ); |
| 368 | drop(replica); | 363 | drop(replica); |
| 369 | let reopened = Replica::open(&path).unwrap(); | 364 | let reopened = Replica::open(&path).unwrap(); |
| 370 | assert_eq!(reopened.snapshot().unwrap(), final_bytes); | 365 | assert_eq!(target(&reopened.snapshot().unwrap()).2, content); |
| 371 | assert_eq!(reopened.pending().unwrap(), pending); | 366 | assert_eq!(reopened.pending().unwrap(), pending); |
| 372 | } | 367 | } |
| 373 | 368 | ||
| ... | @@ -375,11 +370,10 @@ fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() { | ... | @@ -375,11 +370,10 @@ fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() { |
| 375 | fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { | 370 | fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { |
| 376 | let dir = tempfile::tempdir().unwrap(); | 371 | let dir = tempfile::tempdir().unwrap(); |
| 377 | let path = dir.path().join("model.sqlite"); | 372 | let path = dir.path().join("model.sqlite"); |
| 378 | let original = "ab🚀ab🦀 é repeated repeated".to_owned(); | 373 | let mut text = "ab🚀ab🦀 é repeated repeated".to_owned(); |
| 379 | let mut text = original.clone(); | ||
| 380 | let source = onestore::create_section("model.one", &text, "Fixture").unwrap(); | 374 | let source = onestore::create_section("model.one", &text, "Fixture").unwrap(); |
| 381 | let mut replica = Replica::create(&path, &source).unwrap(); | 375 | let mut replica = Replica::create(&path, &source).unwrap(); |
| 382 | let (space, object, _) = target(&source); | 376 | let (_, object, _) = target(&source); |
| 383 | let mut random = 911_u64; | 377 | let mut random = 911_u64; |
| 384 | let mut next = || { | 378 | let mut next = || { |
| 385 | random ^= random << 13; | 379 | random ^= random << 13; |
| ... | @@ -387,8 +381,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { | ... | @@ -387,8 +381,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { |
| 387 | random ^= random << 17; | 381 | random ^= random << 17; |
| 388 | random | 382 | random |
| 389 | }; | 383 | }; |
| 390 | let mut acknowledged = 0; | 384 | let mut acknowledged = Vec::new(); |
| 391 | let mut pending = Vec::new(); | ||
| 392 | for step in 0..512 { | 385 | for step in 0..512 { |
| 393 | let boundaries: Vec<_> = text | 386 | let boundaries: Vec<_> = text |
| 394 | .char_indices() | 387 | .char_indices() |
| ... | @@ -403,47 +396,45 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { | ... | @@ -403,47 +396,45 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { |
| 403 | let replacement = ["", "🐈", "日本語", "repeated", "é", "ab🦀ab"][next() as usize % 6]; | 396 | let replacement = ["", "🐈", "日本語", "repeated", "é", "ab🦀ab"][next() as usize % 6]; |
| 404 | let mut expected = text.clone(); | 397 | let mut expected = text.clone(); |
| 405 | expected.replace_range(bytes, replacement); | 398 | expected.replace_range(bytes, replacement); |
| 406 | let before = replica.snapshot().unwrap(); | ||
| 407 | let acknowledgement = model_ops::save(&replica, object, |page| { | 399 | let acknowledgement = model_ops::save(&replica, object, |page| { |
| 408 | model_ops::replace_text(page, object, range.clone(), replacement) | 400 | model_ops::replace_text(page, object, range.clone(), replacement) |
| 409 | }) | 401 | }) |
| 410 | .unwrap(); | 402 | .unwrap(); |
| 411 | if let Some(id) = acknowledgement { | 403 | match acknowledgement { |
| 412 | assert_ne!(text, expected); | 404 | Some(id) => { |
| 413 | acknowledged += 1; | 405 | assert_ne!(text, expected); |
| 414 | pending = replica.pending().unwrap(); | 406 | assert!(acknowledged.last().is_none_or(|last| *last < id)); |
| 415 | assert_eq!(pending.len(), 1); | 407 | acknowledged.push(id); |
| 416 | assert_eq!((pending[0].id, pending[0].space), (1, space)); | ||
| 417 | let saved = intent(&replica, 0); | ||
| 418 | assert_eq!(id, 1); | ||
| 419 | assert_eq!(text_of(&saved.before, object), original); | ||
| 420 | assert_eq!(text_of(&saved.after, object), expected); | ||
| 421 | if step % 37 == 0 { | ||
| 422 | let (_, mut stale) = model_ops::locate(&before, object); | ||
| 423 | model_ops::replace_text(&mut stale, object, 0..0, "stale"); | ||
| 424 | assert!( | ||
| 425 | matches!(replica.save(&before, space, &stale, "Author"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy) | ||
| 426 | ); | ||
| 427 | } | 408 | } |
| 428 | } else { | 409 | None => assert_eq!(text, expected), |
| 429 | assert_eq!(text, expected); | ||
| 430 | } | 410 | } |
| 431 | text = expected; | 411 | text = expected; |
| 432 | let source = replica.snapshot().unwrap(); | 412 | assert_eq!( |
| 433 | assert_eq!(target(&source).2, text, "seed 911, step {step}"); | 413 | target(&replica.snapshot().unwrap()).2, |
| 414 | text, | ||
| 415 | "seed 911, step {step}" | ||
| 416 | ); | ||
| 434 | if step % 37 == 0 { | 417 | if step % 37 == 0 { |
| 418 | let pending = replica.pending().unwrap(); | ||
| 435 | drop(replica); | 419 | drop(replica); |
| 436 | replica = Replica::open(&path).unwrap(); | 420 | replica = Replica::open(&path).unwrap(); |
| 437 | assert_eq!(replica.snapshot().unwrap(), source); | 421 | assert_eq!(target(&replica.snapshot().unwrap()).2, text); |
| 438 | assert_eq!(replica.pending().unwrap(), pending); | 422 | assert_eq!(replica.pending().unwrap(), pending); |
| 439 | } | 423 | } |
| 440 | } | 424 | } |
| 441 | assert!(acknowledged > 256, "Most random edits change the text"); | 425 | assert!( |
| 442 | let source = replica.snapshot().unwrap(); | 426 | acknowledged.len() > 256, |
| 443 | drop(replica); | 427 | "Most random edits change the text" |
| 444 | let replica = Replica::open(&path).unwrap(); | 428 | ); |
| 445 | assert_eq!(replica.pending().unwrap(), pending); | 429 | assert_eq!( |
| 446 | assert_eq!(replica.snapshot().unwrap(), source); | 430 | replica |
| 431 | .pending() | ||
| 432 | .unwrap() | ||
| 433 | .iter() | ||
| 434 | .map(|edit| edit.id) | ||
| 435 | .collect::<Vec<_>>(), | ||
| 436 | acknowledged | ||
| 437 | ); | ||
| 447 | } | 438 | } |
| 448 | 439 | ||
| 449 | #[test] | 440 | #[test] |
| ... | @@ -465,71 +456,43 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { | ... | @@ -465,71 +456,43 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { |
| 465 | let mut objects = Vec::new(); | 456 | let mut objects = Vec::new(); |
| 466 | barrier.wait(); | 457 | barrier.wait(); |
| 467 | for sequence in 0..4 { | 458 | for sequence in 0..4 { |
| 459 | assert!(Instant::now() < deadline, "Client {client} stopped"); | ||
| 468 | let content = if sequence == 0 { | 460 | let content = if sequence == 0 { |
| 469 | format!("Client {client}") | 461 | format!("Client {client}") |
| 470 | } else { | 462 | } else { |
| 471 | format!("Paragraph {client}:{sequence}") | 463 | format!("Paragraph {client}:{sequence}") |
| 472 | }; | 464 | }; |
| 473 | let mut inserted = None; | 465 | let mut inserted = None; |
| 474 | loop { | 466 | let id = model_ops::save(cache, anchor, |page| { |
| 475 | assert!( | 467 | let text = if sequence == 0 { |
| 476 | Instant::now() < deadline, | 468 | model_ops::insert_outline( |
| 477 | "Client {client} stopped at insertion {sequence}" | 469 | page, |
| 478 | ); | 470 | 72.0, |
| 479 | let result = model_ops::save(cache, anchor, |page| { | 471 | 144.0 + client as f32 * 72.0, |
| 480 | let text = if sequence == 0 { | 472 | &content, |
| 481 | model_ops::insert_outline( | 473 | ) |
| 482 | page, | 474 | .2 |
| 483 | 72.0, | 475 | } else { |
| 484 | 144.0 + client as f32 * 72.0, | 476 | model_ops::insert_after(page, *objects.last().unwrap(), &content).1 |
| 485 | &content, | 477 | }; |
| 486 | ) | 478 | let end = content.encode_utf16().count() as u32; |
| 487 | .2 | 479 | model_ops::restyle(page, text, 0..end, |format| format.italic = None); |
| 488 | } else { | 480 | model_ops::restyle(page, text, 0..6, italic); |
| 489 | model_ops::insert_after( | 481 | inserted = Some(text); |
| 490 | page, | 482 | }) |
| 491 | *objects.last().unwrap(), | 483 | .unwrap() |
| 492 | &content, | 484 | .unwrap(); |
| 493 | ) | 485 | ids.push(id); |
| 494 | .1 | 486 | objects.push(inserted.unwrap()); |
| 495 | }; | ||
| 496 | let end = content.encode_utf16().count() as u32; | ||
| 497 | model_ops::restyle(page, text, 0..end, |format| { | ||
| 498 | format.italic = None | ||
| 499 | }); | ||
| 500 | model_ops::restyle(page, text, 0..6, italic); | ||
| 501 | inserted = Some(text); | ||
| 502 | }); | ||
| 503 | match result { | ||
| 504 | Ok(Some(id)) => { | ||
| 505 | ids.push(id); | ||
| 506 | objects.push(inserted.unwrap()); | ||
| 507 | break; | ||
| 508 | } | ||
| 509 | Err(Error::Io(e)) if e.kind() == ErrorKind::ResourceBusy => {} | ||
| 510 | other => panic!("{other:?}"), | ||
| 511 | } | ||
| 512 | } | ||
| 513 | if sequence == 0 { | 487 | if sequence == 0 { |
| 514 | continue; | 488 | continue; |
| 515 | } | 489 | } |
| 516 | let text = *objects.last().unwrap(); | 490 | let text = *objects.last().unwrap(); |
| 517 | loop { | 491 | ids.push( |
| 518 | assert!( | 492 | cache |
| 519 | Instant::now() < deadline, | 493 | .apply(model_ops::AUTHOR, typed(sid, text, "Edited ")) |
| 520 | "Client {client} stopped at text {sequence}" | 494 | .unwrap(), |
| 521 | ); | 495 | ); |
| 522 | match model_ops::save(cache, text, |page| { | ||
| 523 | model_ops::replace_text(page, text, 0..0, "Edited ") | ||
| 524 | }) { | ||
| 525 | Ok(Some(id)) => { | ||
| 526 | ids.push(id); | ||
| 527 | break; | ||
| 528 | } | ||
| 529 | Err(Error::Io(e)) if e.kind() == ErrorKind::ResourceBusy => {} | ||
| 530 | other => panic!("{other:?}"), | ||
| 531 | } | ||
| 532 | } | ||
| 533 | } | 496 | } |
| 534 | (ids, objects) | 497 | (ids, objects) |
| 535 | }) | 498 | }) |
| ... | @@ -544,12 +507,14 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { | ... | @@ -544,12 +507,14 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { |
| 544 | .iter() | 507 | .iter() |
| 545 | .flat_map(|(ids, _)| ids.iter().copied()) | 508 | .flat_map(|(ids, _)| ids.iter().copied()) |
| 546 | .collect(); | 509 | .collect(); |
| 547 | assert_eq!(all.iter().map(|(ids, _)| ids.len()).sum::<usize>(), 84); | 510 | assert_eq!(ids.len(), 84); |
| 548 | assert_eq!(ids.len(), 1); | ||
| 549 | let snapshot = cache.snapshot().unwrap(); | 511 | let snapshot = cache.snapshot().unwrap(); |
| 550 | drop(cache); | 512 | drop(cache); |
| 551 | let cache = Replica::open(&path).unwrap(); | 513 | let cache = Replica::open(&path).unwrap(); |
| 552 | assert_eq!(cache.snapshot().unwrap(), snapshot); | 514 | assert_eq!( |
| 515 | server::pages(&cache.snapshot().unwrap()), | ||
| 516 | server::pages(&snapshot) | ||
| 517 | ); | ||
| 553 | assert_eq!( | 518 | assert_eq!( |
| 554 | cache | 519 | cache |
| 555 | .pending() | 520 | .pending() |
| ... | @@ -588,8 +553,8 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { | ... | @@ -588,8 +553,8 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { |
| 588 | } | 553 | } |
| 589 | 554 | ||
| 590 | #[test] | 555 | #[test] |
| 591 | fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { | 556 | fn unrecognized_persisted_ops_are_rejected_without_dropping_fields() { |
| 592 | for operation in ["Page", "CreatePage", "Pages", "DeletePages"] { | 557 | for kind in ["Text", "Create", "Pages", "Delete"] { |
| 593 | let directory = tempfile::tempdir().unwrap(); | 558 | let directory = tempfile::tempdir().unwrap(); |
| 594 | let path = directory.path().join("unknown.sqlite"); | 559 | let path = directory.path().join("unknown.sqlite"); |
| 595 | let first = onestore::create_section("unknown.one", "Original", "Author").unwrap(); | 560 | let first = onestore::create_section("unknown.one", "Original", "Author").unwrap(); |
| ... | @@ -598,52 +563,47 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { | ... | @@ -598,52 +563,47 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { |
| 598 | .unwrap() | 563 | .unwrap() |
| 599 | .as_bytes() | 564 | .as_bytes() |
| 600 | .to_vec(); | 565 | .to_vec(); |
| 601 | let (_, oid, _) = target(&source); | 566 | let (space, oid, _) = target(&source); |
| 602 | let store = Store::parse(&source).unwrap(); | 567 | let store = Store::parse(&source).unwrap(); |
| 603 | let index = RevisionIndex::parse(&store).unwrap(); | 568 | let index = RevisionIndex::parse(&store).unwrap(); |
| 604 | let sid = Document::parse(&index).unwrap().pages().unwrap()[1].0; | 569 | let sid = Document::parse(&index).unwrap().pages().unwrap()[1].0; |
| 605 | let cache = Replica::create(&path, &source).unwrap(); | 570 | let cache = Replica::create(&path, &source).unwrap(); |
| 606 | match operation { | 571 | let section = |op| { |
| 607 | "Page" => { | 572 | server::section_op(&cache, op); |
| 608 | model_ops::save(&cache, oid, |page| { | 573 | }; |
| 609 | model_ops::replace_text(page, oid, 0..0, "New ") | 574 | match kind { |
| 610 | }) | 575 | "Text" => { |
| 611 | .unwrap() | 576 | cache.apply("Author", typed(space, oid, "New ")).unwrap(); |
| 612 | .unwrap(); | ||
| 613 | } | ||
| 614 | "CreatePage" => { | ||
| 615 | let page = onestore::PageCreation::new(None, Some("Created"), "Author").unwrap(); | ||
| 616 | cache.create_page(&source, &page).unwrap().unwrap(); | ||
| 617 | } | ||
| 618 | "Pages" => { | ||
| 619 | cache | ||
| 620 | .pages(&source, &[onestore::PageEdit::set_level(sid, 2).unwrap()]) | ||
| 621 | .unwrap() | ||
| 622 | .unwrap(); | ||
| 623 | } | ||
| 624 | _ => { | ||
| 625 | cache.delete_pages(&source, &[sid]).unwrap().unwrap(); | ||
| 626 | } | 577 | } |
| 578 | "Create" => section(SectionOp::Create( | ||
| 579 | onestore::PageCreation::new(None, Some("Created"), "Author").unwrap(), | ||
| 580 | )), | ||
| 581 | "Pages" => section(SectionOp::Pages(vec![ | ||
| 582 | onestore::PageEdit::set_level(sid, 2).unwrap(), | ||
| 583 | ])), | ||
| 584 | _ => section(SectionOp::Delete(vec![sid])), | ||
| 627 | } | 585 | } |
| 628 | drop(cache); | 586 | drop(cache); |
| 629 | let db = rusqlite::Connection::open(&path).unwrap(); | 587 | let encoded: String = rusqlite::Connection::open(&path) |
| 630 | let encoded: String = db | 588 | .unwrap() |
| 631 | .query_row("SELECT operation FROM edits", [], |r| r.get(0)) | 589 | .query_row("SELECT edit FROM edits", [], |r| r.get(0)) |
| 632 | .unwrap(); | 590 | .unwrap(); |
| 633 | let mut value: serde_json::Value = serde_json::from_str(&encoded).unwrap(); | 591 | for target in ["", "/ops/0", "/ops/0/Page/op/Text", "/ops/0/Section"] { |
| 634 | if value[operation].is_object() { | 592 | let mut value: serde_json::Value = serde_json::from_str(&encoded).unwrap(); |
| 635 | value[operation]["future_option"] = true.into(); | 593 | let Some(object) = value.pointer_mut(target).and_then(|v| v.as_object_mut()) else { |
| 636 | } else { | 594 | continue; |
| 637 | value["future_option"] = true.into(); | 595 | }; |
| 596 | object.insert("future_option".into(), true.into()); | ||
| 597 | rusqlite::Connection::open(&path) | ||
| 598 | .unwrap() | ||
| 599 | .execute("UPDATE edits SET edit=?1", [value.to_string()]) | ||
| 600 | .unwrap(); | ||
| 601 | let before = fs::read(&path).unwrap(); | ||
| 602 | assert!( | ||
| 603 | matches!(Replica::open(&path),Err(Error::Io(error))if error.kind()==ErrorKind::InvalidData), | ||
| 604 | "{kind} {target}" | ||
| 605 | ); | ||
| 606 | assert_eq!(fs::read(&path).unwrap(), before); | ||
| 638 | } | 607 | } |
| 639 | db.execute("UPDATE edits SET operation=?1", [value.to_string()]) | ||
| 640 | .unwrap(); | ||
| 641 | drop(db); | ||
| 642 | let before = fs::read(&path).unwrap(); | ||
| 643 | assert!( | ||
| 644 | matches!(Replica::open(&path),Err(Error::Io(error))if error.kind()==ErrorKind::InvalidData), | ||
| 645 | "{operation}" | ||
| 646 | ); | ||
| 647 | assert_eq!(fs::read(&path).unwrap(), before); | ||
| 648 | } | 608 | } |
| 649 | } | 609 | } |
crates/notebook/tests/migrate.rs created+384| ... | @@ -0,0 +1,384 @@ | ||
| 1 | //! Converting schema-14 caches: whole-page edits become ops, verified page by page against | ||
| 2 | //! the old working image, with a recovery archive first and nothing changed on a mismatch. | ||
| 3 | |||
| 4 | use notebook::{ConflictKind, EditStatus, Replica, Resolution}; | ||
| 5 | use onestore::{ExGuid, PageCreation, PageEdit, PreparedEdit, page::Page}; | ||
| 6 | use rusqlite::{Connection, params}; | ||
| 7 | use serde_json::json; | ||
| 8 | use std::path::Path; | ||
| 9 | |||
| 10 | #[path = "support/server.rs"] | ||
| 11 | mod server; | ||
| 12 | use server::*; | ||
| 13 | #[path = "support/model_ops.rs"] | ||
| 14 | mod model_ops; | ||
| 15 | |||
| 16 | const SOURCE: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); | ||
| 17 | |||
| 18 | /// The body pages of the fixture with their first body text. | ||
| 19 | fn 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. | ||
| 35 | enum 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. | ||
| 44 | fn 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 | |||
| 145 | fn root(image: &[u8]) -> ExGuid { | ||
| 146 | onestore::RevisionIndex::parse(&onestore::Store::parse(image).unwrap()) | ||
| 147 | .unwrap() | ||
| 148 | .root | ||
| 149 | } | ||
| 150 | |||
| 151 | fn 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 | |||
| 165 | fn 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] | ||
| 172 | fn 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] | ||
| 258 | fn 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] | ||
| 352 | fn a_page_the_conversion_cannot_reproduce_leaves_the_cache_untouched() { | ||
| 353 | let body = body_pages(SOURCE); | ||
| 354 | let (a, a_text) = body[0]; | ||
| 355 | let directory = tempfile::tempdir().unwrap(); | ||
| 356 | let path = directory.path().join("cache.sqlite"); | ||
| 357 | // The working image holds a page the queued edit does not reach. | ||
| 358 | let stray = PreparedEdit::page(SOURCE, a, &appended(SOURCE, a, a_text, " stray"), "Author") | ||
| 359 | .unwrap() | ||
| 360 | .as_bytes() | ||
| 361 | .to_vec(); | ||
| 362 | v14( | ||
| 363 | &path, | ||
| 364 | SOURCE, | ||
| 365 | &[Queued::Page(a, appended(SOURCE, a, a_text, " queued"))], | ||
| 366 | Some(&stray), | ||
| 367 | ); | ||
| 368 | let before = std::fs::read(&path).unwrap(); | ||
| 369 | let Err(notebook::Error::Io(error)) = Replica::open(&path) else { | ||
| 370 | panic!("the conversion must fail") | ||
| 371 | }; | ||
| 372 | assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); | ||
| 373 | assert!(error.to_string().contains(".v14-recovery"), "{error}"); | ||
| 374 | assert_eq!(std::fs::read(&path).unwrap(), before); | ||
| 375 | assert!(archive(&path).exists()); | ||
| 376 | let version: u32 = Connection::open(archive(&path)) | ||
| 377 | .unwrap() | ||
| 378 | .pragma_query_value(None, "user_version", |row| row.get(0)) | ||
| 379 | .unwrap(); | ||
| 380 | assert_eq!(version, 14); | ||
| 381 | // A second open tries again from the same untouched cache. | ||
| 382 | assert!(Replica::open(&path).is_err()); | ||
| 383 | assert_eq!(std::fs::read(&path).unwrap(), before); | ||
| 384 | } | ||
crates/notebook/tests/session.rs+26-15| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | use notebook::{ | 1 | use notebook::{ |
| 2 | ConflictKind, EditStatus, Recovery, | 2 | ConflictKind, EditStatus, Recovery, Resolution, |
| 3 | session::{Event, Notebook, QueuedEdit, Save, Section}, | 3 | session::{Event, Notebook, QueuedEdit, Save, Section}, |
| 4 | }; | 4 | }; |
| 5 | use onestore::{ExGuid, PreparedEdit, page::Page}; | 5 | use onestore::{ExGuid, PreparedEdit, page::Page}; |
| ... | @@ -62,10 +62,14 @@ fn wait(section: &Section, mut accept: impl FnMut(&Event) -> bool) { | ... | @@ -62,10 +62,14 @@ fn wait(section: &Section, mut accept: impl FnMut(&Event) -> bool) { |
| 62 | } | 62 | } |
| 63 | 63 | ||
| 64 | fn published(section: &Section, id: u64) { | 64 | fn published(section: &Section, id: u64) { |
| 65 | wait( | 65 | let deadline = Instant::now() + Duration::from_secs(20); |
| 66 | section, | 66 | while !matches!( |
| 67 | |event| matches!(event, Event::Attempt { id: n, status: EditStatus::Published { .. } } if *n == id), | 67 | section.status(id).unwrap(), |
| 68 | ); | 68 | Some(EditStatus::Published { .. }) |
| 69 | ) { | ||
| 70 | assert!(Instant::now() < deadline, "the edit was not published"); | ||
| 71 | std::thread::sleep(Duration::from_millis(20)); | ||
| 72 | } | ||
| 69 | } | 73 | } |
| 70 | 74 | ||
| 71 | fn stored_page(file: &Path, space: ExGuid) -> Page { | 75 | fn stored_page(file: &Path, space: ExGuid) -> Page { |
| ... | @@ -79,6 +83,13 @@ fn assert_same(actual: Page, expected: &Page) { | ... | @@ -79,6 +83,13 @@ fn assert_same(actual: Page, expected: &Page) { |
| 79 | assert_eq!(actual, expected); | 83 | assert_eq!(actual, expected); |
| 80 | } | 84 | } |
| 81 | 85 | ||
| 86 | #[test] | ||
| 87 | fn a_section_is_shared_between_threads() { | ||
| 88 | fn shared<T: Send + Sync>() {} | ||
| 89 | shared::<Section>(); | ||
| 90 | shared::<notebook::Replica>(); | ||
| 91 | } | ||
| 92 | |||
| 82 | #[test] | 93 | #[test] |
| 83 | fn a_section_opens_through_its_replica_and_a_save_reaches_the_file_and_survives_relaunch() { | 94 | fn a_section_opens_through_its_replica_and_a_save_reaches_the_file_and_survives_relaunch() { |
| 84 | let directory = tempfile::tempdir().unwrap(); | 95 | let directory = tempfile::tempdir().unwrap(); |
| ... | @@ -765,7 +776,7 @@ fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recov | ... | @@ -765,7 +776,7 @@ fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recov |
| 765 | mod server; | 776 | mod server; |
| 766 | 777 | ||
| 767 | /// An uncertain attempt survives a restart as `AwaitingConfirmation`; after exporting the | 778 | /// 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 |
| 769 | /// path records a receipt for the uncertain attempt. | 780 | /// path records a receipt for the uncertain attempt. |
| 770 | #[test] | 781 | #[test] |
| 771 | fn an_uncertain_attempt_is_released_after_restart_by_review() { | 782 | fn an_uncertain_attempt_is_released_after_restart_by_review() { |
| ... | @@ -778,10 +789,8 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() { | ... | @@ -778,10 +789,8 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() { |
| 778 | let replica = notebook::Replica::create(&cache, &source).unwrap(); | 789 | let replica = notebook::Replica::create(&cache, &source).unwrap(); |
| 779 | let space = space_of(&source); | 790 | let space = space_of(&source); |
| 780 | let page = model_ops::page_of(&source, space); | 791 | let page = model_ops::page_of(&source, space); |
| 781 | let text = first_text(&page); | ||
| 782 | let local = edited(&page, "Uncertain "); | 792 | let local = edited(&page, "Uncertain "); |
| 783 | let id = replica | 793 | let id = model_ops::save_as(&replica, space, &local, "Author") |
| 784 | .save(&source, space, &local, "Author") | ||
| 785 | .unwrap() | 794 | .unwrap() |
| 786 | .unwrap(); | 795 | .unwrap(); |
| 787 | let mut faulty = server::Server::new(&source); | 796 | let mut faulty = server::Server::new(&source); |
| ... | @@ -815,14 +824,12 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() { | ... | @@ -815,14 +824,12 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() { |
| 815 | assert_same(section.page(space).unwrap(), &local); | 824 | assert_same(section.page(space).unwrap(), &local); |
| 816 | let archive = directory.path().join("review.sqlite"); | 825 | let archive = directory.path().join("review.sqlite"); |
| 817 | if continued { | 826 | if continued { |
| 818 | let mut reviewed = section.remote_page(space).unwrap(); | 827 | section.release(id, &archive, Resolution::Mine).unwrap(); |
| 819 | model_ops::replace_text(&mut reviewed, text, 0..0, "Reviewed "); | ||
| 820 | section.release(id, &archive, Some(&reviewed)).unwrap(); | ||
| 821 | assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending)); | 828 | assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending)); |
| 822 | published(&section, id); | 829 | published(&section, id); |
| 823 | assert_same(stored_page(&file, space), &reviewed); | 830 | assert_same(stored_page(&file, space), &local); |
| 824 | } else { | 831 | } else { |
| 825 | section.release(id, &archive, None).unwrap(); | 832 | section.release(id, &archive, Resolution::Theirs).unwrap(); |
| 826 | assert_eq!( | 833 | assert_eq!( |
| 827 | section.status(id).unwrap(), | 834 | section.status(id).unwrap(), |
| 828 | Some(EditStatus::Archived { | 835 | Some(EditStatus::Archived { |
| ... | @@ -841,7 +848,11 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() { | ... | @@ -841,7 +848,11 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() { |
| 841 | assert!(section.queue().unwrap().is_empty()); | 848 | assert!(section.queue().unwrap().is_empty()); |
| 842 | assert!( | 849 | assert!( |
| 843 | section | 850 | section |
| 844 | .release(id, directory.path().join("again.sqlite"), None) | 851 | .release( |
| 852 | id, | ||
| 853 | directory.path().join("again.sqlite"), | ||
| 854 | Resolution::Theirs | ||
| 855 | ) | ||
| 845 | .is_err() | 856 | .is_err() |
| 846 | ); | 857 | ); |
| 847 | section.close().unwrap(); | 858 | section.close().unwrap(); |
crates/notebook/tests/smb_queue.rs created+249| ... | @@ -0,0 +1,249 @@ | ||
| 1 | //! The op queue against a disposable Samba lab (`ONESTORE_SMB_LAB=127.0.0.1:PORT`, share | ||
| 2 | //! `agent`): publications read only the file's header, a native change is read once and | ||
| 3 | //! merged, and a session edits through the embedded client. | ||
| 4 | #![cfg(feature = "smb")] | ||
| 5 | |||
| 6 | use notebook::{ | ||
| 7 | EditStatus, Remote, Replica, SmbRemote, | ||
| 8 | session::{Event, Section}, | ||
| 9 | smb::{Client, Credentials}, | ||
| 10 | }; | ||
| 11 | use onestore::{ | ||
| 12 | CommitError, ExGuid, Stamp, Transaction, | ||
| 13 | op::{Edit, Op, PageOp}, | ||
| 14 | }; | ||
| 15 | use std::{ | ||
| 16 | io, | ||
| 17 | time::{Duration, Instant, SystemTime, UNIX_EPOCH}, | ||
| 18 | }; | ||
| 19 | |||
| 20 | fn 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 | |||
| 30 | fn 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. | ||
| 41 | struct Counted { | ||
| 42 | remote: SmbRemote, | ||
| 43 | reads: usize, | ||
| 44 | stamps: usize, | ||
| 45 | } | ||
| 46 | |||
| 47 | impl 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. | ||
| 65 | fn 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 | |||
| 83 | fn 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 | |||
| 97 | fn 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"] | ||
| 116 | fn 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"] | ||
| 183 | fn live_session_applies_edits_through_the_embedded_client_and_reopens() { | ||
| 184 | let lab = client(); | ||
| 185 | let path = unique("session"); | ||
| 186 | let source = onestore::create_section(&path, "Session", "Author").unwrap(); | ||
| 187 | lab.create(&path, &source).unwrap(); | ||
| 188 | let directory = tempfile::tempdir().unwrap(); | ||
| 189 | let cache = directory.path().join("cache.sqlite"); | ||
| 190 | let section = Section::resume_smb( | ||
| 191 | path.clone(), | ||
| 192 | Replica::create(&cache, &source).unwrap(), | ||
| 193 | 1 << 24, | ||
| 194 | || { | ||
| 195 | Client::connect( | ||
| 196 | &std::env::var("ONESTORE_SMB_LAB").unwrap(), | ||
| 197 | "agent", | ||
| 198 | Credentials::default(), | ||
| 199 | Duration::from_secs(5), | ||
| 200 | ) | ||
| 201 | }, | ||
| 202 | || {}, | ||
| 203 | ) | ||
| 204 | .unwrap(); | ||
| 205 | let (space, text) = target(section.replica()); | ||
| 206 | for n in 0..10 { | ||
| 207 | section | ||
| 208 | .apply("Author", typed(space, text, n, &n.to_string())) | ||
| 209 | .unwrap(); | ||
| 210 | } | ||
| 211 | // Applying does not wait: the edits are done when the file holds them. | ||
| 212 | let remote_text = |bytes: Vec<u8>| { | ||
| 213 | let arena = onestore::Arena::default(); | ||
| 214 | let section = onestore::Section::open(&arena, bytes).unwrap(); | ||
| 215 | let page = section.page(space).unwrap(); | ||
| 216 | page.objects | ||
| 217 | .iter() | ||
| 218 | .find_map(|object| match object { | ||
| 219 | onestore::page::PageObject::Outline(outline) => { | ||
| 220 | outline.paragraphs.iter().find_map(|p| { | ||
| 221 | p.text() | ||
| 222 | .filter(|t| t.id == text) | ||
| 223 | .map(|t| t.text.text().to_owned()) | ||
| 224 | }) | ||
| 225 | } | ||
| 226 | _ => None, | ||
| 227 | }) | ||
| 228 | .unwrap() | ||
| 229 | }; | ||
| 230 | let deadline = Instant::now() + Duration::from_secs(30); | ||
| 231 | while remote_text(lab.read(&path, 1 << 24).unwrap()) != "0123456789Session" | ||
| 232 | || !section.pending().unwrap().is_empty() | ||
| 233 | { | ||
| 234 | assert!(Instant::now() < deadline, "the edits were not published"); | ||
| 235 | for event in section.events() { | ||
| 236 | assert!( | ||
| 237 | !matches!(event, Event::Rejected { .. } | Event::Failed(_)), | ||
| 238 | "{event:?}" | ||
| 239 | ); | ||
| 240 | } | ||
| 241 | std::thread::sleep(Duration::from_millis(50)); | ||
| 242 | } | ||
| 243 | let published = lab.read(&path, 1 << 24).unwrap(); | ||
| 244 | section.close().unwrap(); | ||
| 245 | let replica = Replica::open(&cache).unwrap(); | ||
| 246 | assert_eq!(text_of(&replica, space, text), "0123456789Session"); | ||
| 247 | assert_eq!(replica.snapshot().unwrap(), published); | ||
| 248 | lab.delete(&path).unwrap(); | ||
| 249 | } | ||
crates/notebook/tests/support/model_ops.rs+63-12| ... | @@ -225,28 +225,79 @@ pub fn delete_paragraph(page: &mut Page, text: ExGuid) { | ... | @@ -225,28 +225,79 @@ pub fn delete_paragraph(page: &mut Page, text: ExGuid) { |
| 225 | panic!("text is on the page"); | 225 | panic!("text is on the page"); |
| 226 | } | 226 | } |
| 227 | 227 | ||
| 228 | /// Saves `edit` applied to the page containing `text`, using the replica's current image. | 228 | /// FILETIME now. |
| 229 | pub 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. | ||
| 237 | pub 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`. | ||
| 247 | pub 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`. | ||
| 273 | pub 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`. | ||
| 229 | pub fn save( | 285 | pub fn save( |
| 230 | cache: &Replica, | 286 | cache: &Replica, |
| 231 | text: ExGuid, | 287 | text: ExGuid, |
| 232 | edit: impl FnOnce(&mut Page), | 288 | edit: impl FnOnce(&mut Page), |
| 233 | ) -> Result<Option<u64>, notebook::Error> { | 289 | ) -> Result<Option<u64>, notebook::Error> { |
| 234 | let source = cache.snapshot()?; | 290 | let (space, _) = locate(&cache.snapshot()?, text); |
| 235 | let (space, mut page) = locate(&source, text); | 291 | save_page(cache, space, edit) |
| 236 | edit(&mut page); | ||
| 237 | cache.save(&source, space, &page, AUTHOR) | ||
| 238 | } | 292 | } |
| 239 | 293 | ||
| 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. |
| 241 | pub fn review( | 295 | pub fn review( |
| 242 | cache: &Replica, | 296 | cache: &Replica, |
| 243 | id: u64, | 297 | id: u64, |
| 244 | text: ExGuid, | 298 | text: ExGuid, |
| 245 | edit: impl FnOnce(&mut Page), | 299 | edit: impl FnOnce(&mut Page), |
| 246 | ) -> Result<(), notebook::Error> { | 300 | ) -> Result<Option<u64>, notebook::Error> { |
| 247 | let local = cache.snapshot()?; | 301 | cache.resolve(id, notebook::Resolution::Theirs)?; |
| 248 | let remote = cache.remote_snapshot()?; | 302 | save(cache, text, edit) |
| 249 | let (_, mut page) = locate(&remote, text); | ||
| 250 | edit(&mut page); | ||
| 251 | cache.review_page(id, &local, &remote, &page) | ||
| 252 | } | 303 | } |
crates/notebook/tests/support/model_schedule.rs+66-79| ... | @@ -3,7 +3,7 @@ | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 | ||
| 4 | use crate::model_ops::{self, AUTHOR}; | 4 | use crate::model_ops::{self, AUTHOR}; |
| 5 | use crate::server::{Fault, Server}; | 5 | use crate::server::{Fault, Server}; |
| 6 | use notebook::{EditStatus, Operation, Remote, Replica}; | 6 | use notebook::{EditStatus, Remote, Replica, Resolution}; |
| 7 | use onestore::{ | 7 | use onestore::{ |
| 8 | CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, Transaction, | 8 | CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, Transaction, |
| 9 | document::Document, | 9 | document::Document, |
| ... | @@ -126,11 +126,10 @@ pub(crate) fn move_subtree( | ... | @@ -126,11 +126,10 @@ pub(crate) fn move_subtree( |
| 126 | } | 126 | } |
| 127 | } | 127 | } |
| 128 | 128 | ||
| 129 | /// Asserts the publication invariants that survive the page model, then delegates. | 129 | /// Asserts the publication invariants, then delegates. |
| 130 | struct Session<'a> { | 130 | struct Session<'a> { |
| 131 | server: &'a mut Server, | 131 | server: &'a mut Server, |
| 132 | intent: Option<(u64, Operation)>, | 132 | /// The head batch was already attempted, so its publication must never be repeated. |
| 133 | /// The intent was already attempted, so its publication must never be repeated. | ||
| 134 | retired: bool, | 133 | retired: bool, |
| 135 | publications: usize, | 134 | publications: usize, |
| 136 | } | 135 | } |
| ... | @@ -144,18 +143,6 @@ impl Remote for Session<'_> { | ... | @@ -144,18 +143,6 @@ impl Remote for Session<'_> { |
| 144 | assert!(!self.retired, "a retired attempt was replayed"); | 143 | assert!(!self.retired, "a retired attempt was replayed"); |
| 145 | self.publications += 1; | 144 | self.publications += 1; |
| 146 | assert_eq!(self.publications, 1); | 145 | assert_eq!(self.publications, 1); |
| 147 | let Some((_, Operation::Page(intent))) = &self.intent else { | ||
| 148 | panic!("the schedule queues page saves only") | ||
| 149 | }; | ||
| 150 | let mut image = self.server.visible.clone(); | ||
| 151 | if transaction.apply(&mut image).is_ok() | ||
| 152 | && model_ops::page_of(&self.server.visible, SOURCE.1) == intent.before | ||
| 153 | { | ||
| 154 | assert_eq!( | ||
| 155 | shape(&model_ops::page_of(&image, SOURCE.1)), | ||
| 156 | shape(&intent.after) | ||
| 157 | ); | ||
| 158 | } | ||
| 159 | self.server.publish(transaction) | 146 | self.server.publish(transaction) |
| 160 | } | 147 | } |
| 161 | 148 | ||
| ... | @@ -164,6 +151,11 @@ impl Remote for Session<'_> { | ... | @@ -164,6 +151,11 @@ impl Remote for Session<'_> { |
| 164 | } | 151 | } |
| 165 | } | 152 | } |
| 166 | 153 | ||
| 154 | /// The page's shape as the replica's queue leaves it. | ||
| 155 | fn local(cache: &Replica) -> Vec<String> { | ||
| 156 | shape(&cache.page(SOURCE.1).unwrap()) | ||
| 157 | } | ||
| 158 | |||
| 167 | fn statuses(cache: &Replica) -> Vec<(u64, Option<EditStatus>)> { | 159 | fn statuses(cache: &Replica) -> Vec<(u64, Option<EditStatus>)> { |
| 168 | cache | 160 | cache |
| 169 | .pending() | 161 | .pending() |
| ... | @@ -190,12 +182,13 @@ pub fn run(input: &[u8]) { | ... | @@ -190,12 +182,13 @@ pub fn run(input: &[u8]) { |
| 190 | let path = directory.path().join(format!("{actor}.sqlite")); | 182 | let path = directory.path().join(format!("{actor}.sqlite")); |
| 191 | let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap()); | 183 | let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap()); |
| 192 | let snapshot = cache.snapshot().unwrap(); | 184 | let snapshot = cache.snapshot().unwrap(); |
| 185 | let before = local(cache); | ||
| 193 | let pending = cache.pending().unwrap(); | 186 | let pending = cache.pending().unwrap(); |
| 194 | let local = rows(&snapshot); | 187 | let rows_now = rows(&snapshot); |
| 195 | match step[1] % 8 { | 188 | match step[1] % 8 { |
| 196 | 0..=2 => { | 189 | 0..=2 => { |
| 197 | let row = local[usize::from(step[2]) % local.len()].clone(); | 190 | let row = rows_now[usize::from(step[2]) % rows_now.len()].clone(); |
| 198 | let other = local[usize::from(step[4]) % local.len()].clone(); | 191 | let other = rows_now[usize::from(step[4]) % rows_now.len()].clone(); |
| 199 | let change: Box<dyn Fn(&mut Page)> = match step[3] % 6 { | 192 | let change: Box<dyn Fn(&mut Page)> = match step[3] % 6 { |
| 200 | 0 if !row.2.is_empty() => Box::new(move |page| { | 193 | 0 if !row.2.is_empty() => Box::new(move |page| { |
| 201 | model_ops::replace_text( | 194 | model_ops::replace_text( |
| ... | @@ -229,24 +222,20 @@ pub fn run(input: &[u8]) { | ... | @@ -229,24 +222,20 @@ pub fn run(input: &[u8]) { |
| 229 | }), | 222 | }), |
| 230 | _ => Box::new(|_| {}), | 223 | _ => Box::new(|_| {}), |
| 231 | }; | 224 | }; |
| 232 | let mut after = model_ops::page_of(&snapshot, *space); | 225 | let mut after = cache.page(*space).unwrap(); |
| 233 | change(&mut after); | 226 | change(&mut after); |
| 234 | match cache.save(&snapshot, *space, &after, AUTHOR) { | 227 | match model_ops::save_as(cache, *space, &after, AUTHOR) { |
| 235 | Ok(id) => { | 228 | Ok(id) => { |
| 236 | let next = cache.pending().unwrap(); | 229 | let next = cache.pending().unwrap(); |
| 237 | let ids = |edits: &[notebook::PendingEdit]| -> Vec<u64> { | 230 | let ids = |edits: &[notebook::PendingEdit]| -> Vec<u64> { |
| 238 | edits.iter().map(|edit| edit.id).collect() | 231 | edits.iter().map(|edit| edit.id).collect() |
| 239 | }; | 232 | }; |
| 240 | assert_eq!(ids(&next)[..pending.len()], ids(&pending)[..]); | 233 | assert_eq!(ids(&next)[..pending.len()], ids(&pending)[..]); |
| 241 | assert!(next.len() <= pending.len() + 1); | 234 | assert_eq!(next.len(), pending.len() + usize::from(id.is_some())); |
| 242 | assert_eq!(id.is_some(), cache.snapshot().unwrap() != snapshot); | 235 | assert_eq!(local(cache), shape(&after)); |
| 243 | assert_eq!( | ||
| 244 | shape(&model_ops::page_of(&cache.snapshot().unwrap(), *space)), | ||
| 245 | shape(&after) | ||
| 246 | ); | ||
| 247 | } | 236 | } |
| 248 | Err(_) => { | 237 | Err(_) => { |
| 249 | assert_eq!(cache.snapshot().unwrap(), snapshot); | 238 | assert_eq!(local(cache), before); |
| 250 | assert_eq!(cache.pending().unwrap(), pending); | 239 | assert_eq!(cache.pending().unwrap(), pending); |
| 251 | } | 240 | } |
| 252 | } | 241 | } |
| ... | @@ -270,9 +259,6 @@ pub fn run(input: &[u8]) { | ... | @@ -270,9 +259,6 @@ pub fn run(input: &[u8]) { |
| 270 | Some(EditStatus::AwaitingConfirmation { .. }) | 259 | Some(EditStatus::AwaitingConfirmation { .. }) |
| 271 | ) | 260 | ) |
| 272 | }), | 261 | }), |
| 273 | intent: pending | ||
| 274 | .first() | ||
| 275 | .map(|edit| (edit.id, edit.operation.clone())), | ||
| 276 | server: &mut server, | 262 | server: &mut server, |
| 277 | publications: 0, | 263 | publications: 0, |
| 278 | }; | 264 | }; |
| ... | @@ -280,22 +266,27 @@ pub fn run(input: &[u8]) { | ... | @@ -280,22 +266,27 @@ pub fn run(input: &[u8]) { |
| 280 | }; | 266 | }; |
| 281 | server.fault = Fault::None; | 267 | server.fault = Fault::None; |
| 282 | let next = cache.pending().unwrap(); | 268 | let next = cache.pending().unwrap(); |
| 283 | if next.len() == pending.len() { | 269 | // Edits leave the queue oldest first, each with a durable receipt. |
| 284 | assert_eq!(next, pending); | 270 | let left = pending.len() - next.len(); |
| 285 | if !next.is_empty() { | 271 | assert!( |
| 286 | assert_eq!(cache.snapshot().unwrap(), snapshot); | 272 | next.iter() |
| 287 | } | 273 | .all(|edit| pending[left..].iter().any(|kept| kept.id == edit.id)) |
| 288 | } else { | 274 | ); |
| 289 | assert_eq!(next, pending[1..]); | 275 | for edit in &pending[..left] { |
| 290 | let Some(EditStatus::Published { revision }) = | 276 | let Some(EditStatus::Published { revision }) = cache.status(edit.id).unwrap() |
| 291 | cache.status(head.unwrap()).unwrap() | ||
| 292 | else { | 277 | else { |
| 293 | panic!("an intent leaves the queue only with its receipt") | 278 | panic!("an edit leaves the queue only with its receipt") |
| 294 | }; | 279 | }; |
| 295 | durable(&server, revision); | 280 | durable(&server, revision); |
| 296 | } | 281 | } |
| 297 | if next.is_empty() && result.is_ok() { | 282 | if next.is_empty() && result.is_ok() { |
| 298 | assert_eq!(cache.snapshot().unwrap(), cache.remote_snapshot().unwrap()); | 283 | assert_eq!( |
| 284 | local(cache), | ||
| 285 | shape(&model_ops::page_of( | ||
| 286 | &cache.remote_snapshot().unwrap(), | ||
| 287 | *space | ||
| 288 | )) | ||
| 289 | ); | ||
| 299 | } | 290 | } |
| 300 | } | 291 | } |
| 301 | 5 => { | 292 | 5 => { |
| ... | @@ -317,44 +308,40 @@ pub fn run(input: &[u8]) { | ... | @@ -317,44 +308,40 @@ pub fn run(input: &[u8]) { |
| 317 | } | 308 | } |
| 318 | } | 309 | } |
| 319 | 6 => { | 310 | 6 => { |
| 320 | if let Some(head) = pending.first() | 311 | if let Some(conflict) = cache.conflict().unwrap() { |
| 321 | && matches!( | 312 | let keep = if step[2] & 1 == 0 { |
| 322 | cache.status(head.id).unwrap(), | 313 | Resolution::Mine |
| 323 | Some(EditStatus::Conflict(_)) | 314 | } else { |
| 324 | ) | 315 | Resolution::Theirs |
| 325 | { | 316 | }; |
| 326 | let remote = cache.remote_snapshot().unwrap(); | 317 | let remote = model_ops::page_of(&cache.remote_snapshot().unwrap(), *space); |
| 327 | let published = rows(&remote); | 318 | cache.resolve(conflict.id, keep).unwrap(); |
| 328 | let row = published[usize::from(step[2]) % published.len()].clone(); | 319 | match cache.conflict().unwrap() { |
| 329 | let mut after = model_ops::page_of(&remote, *space); | 320 | Some(next) => assert_ne!(next.space, conflict.space), |
| 330 | if !row.2.is_empty() { | 321 | None => assert_eq!( |
| 331 | model_ops::replace_text(&mut after, row.1, 0..1, "R"); | 322 | local(cache), |
| 323 | match keep { | ||
| 324 | Resolution::Mine => before.clone(), | ||
| 325 | Resolution::Theirs => shape(&remote), | ||
| 326 | } | ||
| 327 | ), | ||
| 332 | } | 328 | } |
| 333 | if cache | 329 | let (result, publications) = { |
| 334 | .review_page(head.id, &snapshot, &remote, &after) | 330 | let mut session = Session { |
| 335 | .is_ok() | 331 | retired: false, |
| 336 | { | 332 | server: &mut server, |
| 337 | assert_eq!(cache.status(head.id).unwrap(), Some(EditStatus::Pending)); | 333 | publications: 0, |
| 338 | let (result, publications) = { | ||
| 339 | let mut session = Session { | ||
| 340 | intent: Some(( | ||
| 341 | head.id, | ||
| 342 | cache.pending().unwrap()[0].operation.clone(), | ||
| 343 | )), | ||
| 344 | retired: false, | ||
| 345 | server: &mut server, | ||
| 346 | publications: 0, | ||
| 347 | }; | ||
| 348 | (cache.sync_once(&mut session), session.publications) | ||
| 349 | }; | 334 | }; |
| 350 | assert!(publications <= 1); | 335 | (cache.sync_once(&mut session), session.publications) |
| 351 | if let Ok(Some((id, EditStatus::Published { revision }))) = result { | 336 | }; |
| 352 | assert_eq!(id, head.id); | 337 | assert!(publications <= 1); |
| 353 | durable(&server, revision); | 338 | if let Ok(notebook::Synced { |
| 354 | assert!(cache.pending().unwrap().iter().all(|e| e.id != id)); | 339 | edit: Some((id, EditStatus::Published { revision })), |
| 355 | } | 340 | .. |
| 356 | } else { | 341 | }) = result |
| 357 | assert_eq!(cache.pending().unwrap(), pending); | 342 | { |
| 343 | durable(&server, revision); | ||
| 344 | assert!(cache.pending().unwrap().iter().all(|e| e.id != id)); | ||
| 358 | } | 345 | } |
| 359 | } | 346 | } |
| 360 | } | 347 | } |
| ... | @@ -362,7 +349,7 @@ pub fn run(input: &[u8]) { | ... | @@ -362,7 +349,7 @@ pub fn run(input: &[u8]) { |
| 362 | let recorded = statuses(cache); | 349 | let recorded = statuses(cache); |
| 363 | replicas[actor] = None; | 350 | replicas[actor] = None; |
| 364 | let cache = Replica::open(&path).unwrap(); | 351 | let cache = Replica::open(&path).unwrap(); |
| 365 | assert_eq!(cache.snapshot().unwrap(), snapshot); | 352 | assert_eq!(local(&cache), before); |
| 366 | assert_eq!(cache.pending().unwrap(), pending); | 353 | assert_eq!(cache.pending().unwrap(), pending); |
| 367 | assert_eq!(statuses(&cache), recorded); | 354 | assert_eq!(statuses(&cache), recorded); |
| 368 | replicas[actor] = Some(cache); | 355 | replicas[actor] = Some(cache); |
crates/notebook/tests/support/page_schedule.rs+55-103| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | use crate::disk; | 1 | use crate::{disk, model_ops}; |
| 2 | use notebook::{EditStatus, Operation, Remote, Replica}; | 2 | use notebook::{EditStatus, Remote, Replica, Resolution}; |
| 3 | use onestore::{ | 3 | use onestore::{ |
| 4 | CommitError, ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, Transaction, | 4 | CommitError, ExGuid, PageEdit, PreparedEdit, RevisionIndex, Store, Transaction, |
| 5 | document::{Document, Format, Kind, Layout}, | 5 | document::{Document, Format, Kind, Layout}, |
| 6 | page::{ | 6 | page::{ |
| 7 | Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, | 7 | Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, |
| ... | @@ -97,27 +97,9 @@ pub fn body_outline(page: &mut Page, text: &str) -> ExGuid { | ... | @@ -97,27 +97,9 @@ pub fn body_outline(page: &mut Page, text: &str) -> ExGuid { |
| 97 | id | 97 | id |
| 98 | } | 98 | } |
| 99 | 99 | ||
| 100 | /// The text identity of the first body outline's first paragraph. | 100 | /// Publishes through the crash-injecting disk, asserting each commit is atomic. |
| 101 | fn 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 | |||
| 111 | fn 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 | |||
| 118 | struct Session<'a> { | 101 | struct Session<'a> { |
| 119 | disk: &'a mut disk::Disk, | 102 | disk: &'a mut disk::Disk, |
| 120 | operation: Option<&'a Operation>, | ||
| 121 | } | 103 | } |
| 122 | 104 | ||
| 123 | impl Remote for Session<'_> { | 105 | impl Remote for Session<'_> { |
| ... | @@ -128,41 +110,6 @@ impl Remote for Session<'_> { | ... | @@ -128,41 +110,6 @@ impl Remote for Session<'_> { |
| 128 | fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { | 110 | fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { |
| 129 | let mut image = self.disk.visible.clone(); | 111 | let mut image = self.disk.visible.clone(); |
| 130 | transaction.apply(&mut image).unwrap(); | 112 | transaction.apply(&mut image).unwrap(); |
| 131 | let mut expected = pages(&self.disk.visible); | ||
| 132 | match self.operation.unwrap() { | ||
| 133 | Operation::CreatePage(page) => expected.push((page.space(), page.object(), 1)), | ||
| 134 | Operation::Pages(batch) => { | ||
| 135 | for change in &batch.edits { | ||
| 136 | let at = expected.iter().position(|p| p.0 == change.space()).unwrap(); | ||
| 137 | expected[at].2 = change.level(); | ||
| 138 | if let PagePosition::Before(before) = change.position() { | ||
| 139 | let page = expected.remove(at); | ||
| 140 | let at = before.map_or(expected.len(), |before| { | ||
| 141 | expected.iter().position(|p| p.0 == before).unwrap() | ||
| 142 | }); | ||
| 143 | expected.insert(at, page); | ||
| 144 | } | ||
| 145 | } | ||
| 146 | } | ||
| 147 | Operation::Page(intent) => { | ||
| 148 | let body = body_text(&intent.after); | ||
| 149 | let store = Store::parse(&image).unwrap(); | ||
| 150 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 151 | let document = Document::parse(&index).unwrap(); | ||
| 152 | let matching: Vec<_> = document | ||
| 153 | .spaces | ||
| 154 | .values() | ||
| 155 | .filter_map(|space| { | ||
| 156 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 157 | view.nodes.get(&body) | ||
| 158 | }) | ||
| 159 | .collect(); | ||
| 160 | assert_eq!(matching.len(), 1); | ||
| 161 | assert!(matches!(&matching[0].kind, Kind::RichText { text, .. } if text == BODY)); | ||
| 162 | } | ||
| 163 | _ => panic!(), | ||
| 164 | } | ||
| 165 | assert_eq!(pages(&image), expected); | ||
| 166 | let old = current::current(&self.disk.durable); | 113 | let old = current::current(&self.disk.durable); |
| 167 | let new = current::current(&image); | 114 | let new = current::current(&image); |
| 168 | let result = transaction.commit(self.disk); | 115 | let result = transaction.commit(self.disk); |
| ... | @@ -179,6 +126,16 @@ impl Remote for Session<'_> { | ... | @@ -179,6 +126,16 @@ impl Remote for Session<'_> { |
| 179 | } | 126 | } |
| 180 | } | 127 | } |
| 181 | 128 | ||
| 129 | fn 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 | |||
| 182 | pub fn run(input: &[u8]) { | 139 | pub fn run(input: &[u8]) { |
| 183 | let directory = tempfile::tempdir().unwrap(); | 140 | let directory = tempfile::tempdir().unwrap(); |
| 184 | let mut replicas: [Option<Replica>; 12] = std::array::from_fn(|_| None); | 141 | let mut replicas: [Option<Replica>; 12] = std::array::from_fn(|_| None); |
| ... | @@ -196,21 +153,27 @@ pub fn run(input: &[u8]) { | ... | @@ -196,21 +153,27 @@ pub fn run(input: &[u8]) { |
| 196 | let path = directory.path().join(format!("{actor}.sqlite")); | 153 | let path = directory.path().join(format!("{actor}.sqlite")); |
| 197 | let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, &SOURCE).unwrap()); | 154 | let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, &SOURCE).unwrap()); |
| 198 | let snapshot = cache.snapshot().unwrap(); | 155 | let snapshot = cache.snapshot().unwrap(); |
| 156 | let listed = pages(&snapshot); | ||
| 199 | let pending = cache.pending().unwrap(); | 157 | let pending = cache.pending().unwrap(); |
| 200 | match step[1] % 8 { | 158 | match step[1] % 8 { |
| 201 | 0 | 1 => { | 159 | 0 | 1 => { |
| 202 | let title = (step[2] & 1 != 0).then_some("Same 🦋 é"); | 160 | let title = (step[2] & 1 != 0).then_some("Same 🦋 é"); |
| 203 | let page = onestore::PageCreation::new(None, title, "Author").unwrap(); | 161 | let page = onestore::PageCreation::new(None, title, "Author").unwrap(); |
| 204 | cache.create_page(&snapshot, &page).unwrap().unwrap(); | 162 | section_op(cache, onestore::op::SectionOp::Create(page.clone())).unwrap(); |
| 205 | owned[actor].push((page.space(), page.object())); | 163 | owned[actor].push((page.space(), page.object())); |
| 206 | if step[1] % 8 == 1 { | 164 | if step[1] % 8 == 1 { |
| 207 | let source = cache.snapshot().unwrap(); | 165 | let mut model = cache.page(page.space()).unwrap(); |
| 208 | let mut model = page_of(&source, page.space()); | ||
| 209 | body_outline(&mut model, BODY); | 166 | body_outline(&mut model, BODY); |
| 210 | cache | 167 | model_ops::save_as(cache, page.space(), &model, "Author") |
| 211 | .save(&source, page.space(), &model, "Author") | ||
| 212 | .unwrap() | 168 | .unwrap() |
| 213 | .unwrap(); | 169 | .unwrap(); |
| 170 | let local = cache.page(page.space()).unwrap(); | ||
| 171 | assert!( | ||
| 172 | local | ||
| 173 | .objects | ||
| 174 | .iter() | ||
| 175 | .any(|object| matches!(object, PageObject::Outline(o) if o.paragraphs[0].text().is_some_and(|t| t.text.text() == BODY))) | ||
| 176 | ); | ||
| 214 | } | 177 | } |
| 215 | } | 178 | } |
| 216 | 2 => { | 179 | 2 => { |
| ... | @@ -220,7 +183,7 @@ pub fn run(input: &[u8]) { | ... | @@ -220,7 +183,7 @@ pub fn run(input: &[u8]) { |
| 220 | .collect(); | 183 | .collect(); |
| 221 | replicas[actor] = None; | 184 | replicas[actor] = None; |
| 222 | let cache = Replica::open(&path).unwrap(); | 185 | let cache = Replica::open(&path).unwrap(); |
| 223 | assert!(cache.snapshot().unwrap() == snapshot); | 186 | assert_eq!(pages(&cache.snapshot().unwrap()), listed); |
| 224 | assert_eq!(cache.pending().unwrap(), pending); | 187 | assert_eq!(cache.pending().unwrap(), pending); |
| 225 | assert_eq!( | 188 | assert_eq!( |
| 226 | pending | 189 | pending |
| ... | @@ -240,15 +203,20 @@ pub fn run(input: &[u8]) { | ... | @@ -240,15 +203,20 @@ pub fn run(input: &[u8]) { |
| 240 | let prior = pending | 203 | let prior = pending |
| 241 | .first() | 204 | .first() |
| 242 | .and_then(|edit| cache.status(edit.id).unwrap()); | 205 | .and_then(|edit| cache.status(edit.id).unwrap()); |
| 243 | let mut session = Session { | 206 | let result = cache.sync_once(&mut Session { disk: &mut disk }); |
| 244 | disk: &mut disk, | ||
| 245 | operation: pending.first().map(|edit| &edit.operation), | ||
| 246 | }; | ||
| 247 | let result = cache.sync_once(&mut session); | ||
| 248 | if matches!(prior, Some(EditStatus::AwaitingConfirmation { .. })) { | 207 | if matches!(prior, Some(EditStatus::AwaitingConfirmation { .. })) { |
| 249 | assert!( | 208 | assert!( |
| 250 | result.is_err() | 209 | result.is_err() |
| 251 | || !matches!(result.unwrap(), Some((_, EditStatus::Conflict(_)))) | 210 | || !matches!( |
| 211 | result.as_ref().unwrap().edit, | ||
| 212 | Some((_, EditStatus::Conflict(_))) | ||
| 213 | ) | ||
| 214 | ); | ||
| 215 | } | ||
| 216 | if result.is_ok() && cache.pending().unwrap().is_empty() { | ||
| 217 | assert_eq!( | ||
| 218 | pages(&cache.snapshot().unwrap()), | ||
| 219 | pages(&cache.remote_snapshot().unwrap()) | ||
| 252 | ); | 220 | ); |
| 253 | } | 221 | } |
| 254 | } | 222 | } |
| ... | @@ -257,13 +225,12 @@ pub fn run(input: &[u8]) { | ... | @@ -257,13 +225,12 @@ pub fn run(input: &[u8]) { |
| 257 | disk.fail_at = None; | 225 | disk.fail_at = None; |
| 258 | } | 226 | } |
| 259 | 6 => { | 227 | 6 => { |
| 260 | let order = pages(&snapshot); | ||
| 261 | let count = 1 + usize::from(step[3] & 1 != 0); | 228 | let count = 1 + usize::from(step[3] & 1 != 0); |
| 262 | let selected: Vec<_> = (0..count) | 229 | let selected: Vec<_> = (0..count) |
| 263 | .map(|i| order[(usize::from(step[2]) + i) % order.len()].0) | 230 | .map(|i| listed[(usize::from(step[2]) + i) % listed.len()].0) |
| 264 | .collect(); | 231 | .collect(); |
| 265 | let anchor = (step[4] & 1 != 0) | 232 | let anchor = (step[4] & 1 != 0) |
| 266 | .then_some(order[usize::from(step[5]) % order.len()].0) | 233 | .then_some(listed[usize::from(step[5]) % listed.len()].0) |
| 267 | .filter(|sid| !selected.contains(sid)); | 234 | .filter(|sid| !selected.contains(sid)); |
| 268 | let edits: Vec<_> = selected | 235 | let edits: Vec<_> = selected |
| 269 | .iter() | 236 | .iter() |
| ... | @@ -278,43 +245,28 @@ pub fn run(input: &[u8]) { | ... | @@ -278,43 +245,28 @@ pub fn run(input: &[u8]) { |
| 278 | }) | 245 | }) |
| 279 | .collect(); | 246 | .collect(); |
| 280 | let expected = PreparedEdit::pages(&snapshot, &edits); | 247 | let expected = PreparedEdit::pages(&snapshot, &edits); |
| 281 | match cache.pages(&snapshot, &edits) { | 248 | match section_op(cache, onestore::op::SectionOp::Pages(edits)) { |
| 282 | Ok(id) => { | 249 | Ok(_) => assert_eq!( |
| 283 | let expected = expected.unwrap(); | 250 | pages(&cache.snapshot().unwrap()), |
| 284 | assert_eq!( | 251 | pages(expected.unwrap().as_bytes()) |
| 285 | current::current(&cache.snapshot().unwrap()), | 252 | ), |
| 286 | current::current(expected.as_bytes()) | ||
| 287 | ); | ||
| 288 | assert_eq!(id.is_some(), expected.as_bytes() != snapshot); | ||
| 289 | } | ||
| 290 | Err(_) => { | 253 | Err(_) => { |
| 291 | assert!(expected.is_err()); | 254 | assert!(expected.is_err()); |
| 292 | assert_eq!(cache.snapshot().unwrap(), snapshot); | 255 | assert_eq!(pages(&cache.snapshot().unwrap()), listed); |
| 293 | assert_eq!(cache.pending().unwrap(), pending); | 256 | assert_eq!(cache.pending().unwrap(), pending); |
| 294 | } | 257 | } |
| 295 | } | 258 | } |
| 296 | } | 259 | } |
| 297 | 7 => { | 260 | 7 => { |
| 298 | if let Some(edit) = pending.first() | 261 | if let Some(conflict) = cache.conflict().unwrap() { |
| 299 | && matches!( | 262 | // Taking theirs for the page list would drop the pages this actor made. |
| 300 | cache.status(edit.id).unwrap(), | 263 | let root = listed.iter().all(|(sid, ..)| *sid != conflict.space); |
| 301 | Some(EditStatus::Conflict(_)) | 264 | let keep = if root || step[2] & 1 == 0 { |
| 302 | ) | 265 | Resolution::Mine |
| 303 | && let Operation::Pages(batch) = &edit.operation | 266 | } else { |
| 304 | { | 267 | Resolution::Theirs |
| 305 | let remote = cache.remote_snapshot().unwrap(); | 268 | }; |
| 306 | let order = pages(&remote); | 269 | if cache.resolve(conflict.id, keep).is_err() { |
| 307 | let anchor = (step[2] & 1 != 0) | ||
| 308 | .then_some(order[usize::from(step[3]) % order.len()].0) | ||
| 309 | .filter(|sid| batch.edits.iter().all(|e| e.space() != *sid)); | ||
| 310 | let edits: Vec<_> = batch | ||
| 311 | .edits | ||
| 312 | .iter() | ||
| 313 | .map(|edit| edit.reposition(PagePosition::Before(anchor), 1).unwrap()) | ||
| 314 | .collect(); | ||
| 315 | let result = cache.rebase_pages_conflict(edit.id, &snapshot, &remote, &edits); | ||
| 316 | assert_eq!(cache.snapshot().unwrap(), snapshot); | ||
| 317 | if result.is_err() { | ||
| 318 | assert_eq!(cache.pending().unwrap(), pending); | 270 | assert_eq!(cache.pending().unwrap(), pending); |
| 319 | } | 271 | } |
| 320 | } | 272 | } |
crates/notebook/tests/support/server.rs+32| ... | @@ -133,3 +133,35 @@ pub fn text(source: &[u8]) -> (ExGuid, ExGuid, String) { | ... | @@ -133,3 +133,35 @@ pub fn text(source: &[u8]) -> (ExGuid, ExGuid, String) { |
| 133 | }) | 133 | }) |
| 134 | .unwrap() | 134 | .unwrap() |
| 135 | } | 135 | } |
| 136 | |||
| 137 | /// Every page of an image, in section order: what two images holding the same edits under | ||
| 138 | /// different revision identities share. | ||
| 139 | pub 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. | ||
| 157 | pub fn section_op(cache: &notebook::Replica, op: onestore::op::SectionOp) -> u64 { | ||
| 158 | cache | ||
| 159 | .apply( | ||
| 160 | "Fixture", | ||
| 161 | onestore::op::Edit { | ||
| 162 | at: 133_000_000_000_000_000, | ||
| 163 | ops: vec![onestore::op::Op::Section(op)], | ||
| 164 | }, | ||
| 165 | ) | ||
| 166 | .unwrap() | ||
| 167 | } |
crates/notebook/tests/sync.rs+233-230| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | //! Replica protocol: durable receipts, retired attempts, conflict review and contention. | 1 | //! Replica protocol: durable receipts, retired attempts, conflict resolution and contention. |
| 2 | 2 | ||
| 3 | use notebook::{ConflictKind, EditStatus, Error, Remote, Replica}; | 3 | use notebook::{ConflictKind, EditStatus, Error, Remote, Replica, Resolution}; |
| 4 | use onestore::{CommitError, CommitState, ExGuid, Transaction, page::Page}; | 4 | use onestore::{CommitError, CommitState, ExGuid, Transaction}; |
| 5 | use std::{io, ops::Range}; | 5 | use std::{io, ops::Range}; |
| 6 | 6 | ||
| 7 | #[path = "support/server.rs"] | 7 | #[path = "support/server.rs"] |
| ... | @@ -10,13 +10,6 @@ use server::*; | ... | @@ -10,13 +10,6 @@ use server::*; |
| 10 | #[path = "support/model_ops.rs"] | 10 | #[path = "support/model_ops.rs"] |
| 11 | mod model_ops; | 11 | mod model_ops; |
| 12 | 12 | ||
| 13 | /// The page holding `text`, with a UTF-16 range of that text replaced. | ||
| 14 | fn edited(bytes: &[u8], text: ExGuid, range: Range<u32>, replacement: &str) -> Page { | ||
| 15 | let (_, mut page) = model_ops::locate(bytes, text); | ||
| 16 | model_ops::replace_text(&mut page, text, range, replacement); | ||
| 17 | page | ||
| 18 | } | ||
| 19 | |||
| 20 | /// Saves a replacement of `range` in the page holding `text`. | 13 | /// Saves a replacement of `range` in the page holding `text`. |
| 21 | fn save(cache: &Replica, text: ExGuid, range: Range<u32>, replacement: &str) -> Option<u64> { | 14 | fn save(cache: &Replica, text: ExGuid, range: Range<u32>, replacement: &str) -> Option<u64> { |
| 22 | model_ops::save(cache, text, |page| { | 15 | model_ops::save(cache, text, |page| { |
| ... | @@ -56,16 +49,16 @@ fn an_unchanged_stamp_publishes_and_settles_without_reading_the_remote() { | ... | @@ -56,16 +49,16 @@ fn an_unchanged_stamp_publishes_and_settles_without_reading_the_remote() { |
| 56 | reads: 0, | 49 | reads: 0, |
| 57 | }; | 50 | }; |
| 58 | assert!(matches!( | 51 | assert!(matches!( |
| 59 | cache.sync_once(&mut remote).unwrap(), | 52 | cache.sync_once(&mut remote).unwrap().edit, |
| 60 | Some((published, EditStatus::Published { .. })) if published == id | 53 | Some((published, EditStatus::Published { .. })) if published == id |
| 61 | )); | 54 | )); |
| 62 | assert_eq!(cache.sync_once(&mut remote).unwrap(), None); | 55 | assert_eq!(cache.sync_once(&mut remote).unwrap().edit, None); |
| 63 | assert_eq!(remote.reads, 0); | 56 | assert_eq!(remote.reads, 0); |
| 64 | // Another writer's commit moves the header, so the next step reads the file. | 57 | // Another writer's commit moves the header, so the next step reads the file. |
| 65 | let native = | 58 | let native = |
| 66 | onestore::replace_text(&remote.server.visible, sid, object, 0..0, "Native ").unwrap(); | 59 | onestore::replace_text(&remote.server.visible, sid, object, 0..0, "Native ").unwrap(); |
| 67 | remote.server.visible.clone_from(&native); | 60 | remote.server.visible.clone_from(&native); |
| 68 | assert_eq!(cache.sync_once(&mut remote).unwrap(), None); | 61 | assert_eq!(cache.sync_once(&mut remote).unwrap().edit, None); |
| 69 | assert_eq!(remote.reads, 1); | 62 | assert_eq!(remote.reads, 1); |
| 70 | assert_eq!(cache.snapshot().unwrap(), native); | 63 | assert_eq!(cache.snapshot().unwrap(), native); |
| 71 | } | 64 | } |
| ... | @@ -82,7 +75,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin | ... | @@ -82,7 +75,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin |
| 82 | let cache = Replica::create(&path, &source).unwrap(); | 75 | let cache = Replica::create(&path, &source).unwrap(); |
| 83 | let published = save(&cache, object, 0..0, "Published ").unwrap(); | 76 | let published = save(&cache, object, 0..0, "Published ").unwrap(); |
| 84 | let mut server = Server::new(&source); | 77 | let mut server = Server::new(&source); |
| 85 | let (_, receipt) = cache.sync_once(&mut server).unwrap().unwrap(); | 78 | let (_, receipt) = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 86 | let attempted = save(&cache, object, 0..0, "Uncertain ").unwrap(); | 79 | let attempted = save(&cache, object, 0..0, "Uncertain ").unwrap(); |
| 87 | server.fault = Fault::UnknownBefore; | 80 | server.fault = Fault::UnknownBefore; |
| 88 | assert!(matches!( | 81 | assert!(matches!( |
| ... | @@ -94,10 +87,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin | ... | @@ -94,10 +87,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin |
| 94 | )); | 87 | )); |
| 95 | save(&cache, object, 0..0, "Queued ").unwrap(); | 88 | save(&cache, object, 0..0, "Queued ").unwrap(); |
| 96 | let created = onestore::PageCreation::new(None, Some("Recovery 🦀"), "Fixture").unwrap(); | 89 | let created = onestore::PageCreation::new(None, Some("Recovery 🦀"), "Fixture").unwrap(); |
| 97 | cache | 90 | section_op(&cache, onestore::op::SectionOp::Create(created)); |
| 98 | .create_page(&cache.snapshot().unwrap(), &created) | ||
| 99 | .unwrap() | ||
| 100 | .unwrap(); | ||
| 101 | save(&cache, object, 0..0, "Last ").unwrap(); | 91 | save(&cache, object, 0..0, "Last ").unwrap(); |
| 102 | let working = cache.snapshot().unwrap(); | 92 | let working = cache.snapshot().unwrap(); |
| 103 | let remote = cache.remote_snapshot().unwrap(); | 93 | let remote = cache.remote_snapshot().unwrap(); |
| ... | @@ -109,8 +99,8 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin | ... | @@ -109,8 +99,8 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin |
| 109 | conflicts: 0, | 99 | conflicts: 0, |
| 110 | uncertain_edits: 1, | 100 | uncertain_edits: 1, |
| 111 | published_receipts: 1, | 101 | published_receipts: 1, |
| 112 | working_bytes: working.len() as u64, | 102 | base_bytes: remote.len() as u64, |
| 113 | remote_bytes: remote.len() as u64, | 103 | remote_bytes: 0, |
| 114 | cached_assets: 0, | 104 | cached_assets: 0, |
| 115 | cached_asset_bytes: 0, | 105 | cached_asset_bytes: 0, |
| 116 | }; | 106 | }; |
| ... | @@ -123,7 +113,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin | ... | @@ -123,7 +113,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin |
| 123 | assert!(Recovery::open(&path).is_err()); | 113 | assert!(Recovery::open(&path).is_err()); |
| 124 | let archive = Recovery::open(&archive_path).unwrap(); | 114 | let archive = Recovery::open(&archive_path).unwrap(); |
| 125 | assert_eq!(archive.summary().unwrap(), summary); | 115 | assert_eq!(archive.summary().unwrap(), summary); |
| 126 | assert_eq!(archive.snapshot().unwrap(), working); | 116 | assert_eq!(pages(&archive.snapshot().unwrap()), pages(&working)); |
| 127 | assert_eq!(archive.remote_snapshot().unwrap(), remote); | 117 | assert_eq!(archive.remote_snapshot().unwrap(), remote); |
| 128 | assert_eq!(archive.pending().unwrap(), pending); | 118 | assert_eq!(archive.pending().unwrap(), pending); |
| 129 | assert_eq!(archive.status(published).unwrap(), Some(receipt.clone())); | 119 | assert_eq!(archive.status(published).unwrap(), Some(receipt.clone())); |
| ... | @@ -150,7 +140,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin | ... | @@ -150,7 +140,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin |
| 150 | assert_eq!(std::fs::read(&path).unwrap(), source_file); | 140 | assert_eq!(std::fs::read(&path).unwrap(), source_file); |
| 151 | assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes); | 141 | assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes); |
| 152 | save(&cache, object, 0..0, "Later ").unwrap(); | 142 | save(&cache, object, 0..0, "Later ").unwrap(); |
| 153 | assert_eq!(archive.snapshot().unwrap(), working); | 143 | assert_eq!(pages(&archive.snapshot().unwrap()), pages(&working)); |
| 154 | assert_eq!(archive.pending().unwrap(), pending); | 144 | assert_eq!(archive.pending().unwrap(), pending); |
| 155 | drop(archive); | 145 | drop(archive); |
| 156 | assert_eq!( | 146 | assert_eq!( |
| ... | @@ -193,7 +183,7 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv | ... | @@ -193,7 +183,7 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv |
| 193 | let id = save(&cache, object, 0..8, "Local").unwrap(); | 183 | let id = save(&cache, object, 0..8, "Local").unwrap(); |
| 194 | let changed = onestore::replace_text(&source, space, object, 0..8, "Remote").unwrap(); | 184 | let changed = onestore::replace_text(&source, space, object, 0..8, "Remote").unwrap(); |
| 195 | let mut server = Server::new(&changed); | 185 | let mut server = Server::new(&changed); |
| 196 | let outcome = cache.sync_once(&mut server).unwrap().unwrap(); | 186 | let outcome = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 197 | assert_eq!( | 187 | assert_eq!( |
| 198 | outcome, | 188 | outcome, |
| 199 | (id, EditStatus::Conflict(ConflictKind::ContentChanged)) | 189 | (id, EditStatus::Conflict(ConflictKind::ContentChanged)) |
| ... | @@ -232,7 +222,7 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() { | ... | @@ -232,7 +222,7 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() { |
| 232 | let id = save(&cache, oid, 2..4, "🐈").unwrap(); | 222 | let id = save(&cache, oid, 2..4, "🐈").unwrap(); |
| 233 | let remote = onestore::replace_text(&source, sid, oid, 0..6, "Xab🦀cYd").unwrap(); | 223 | let remote = onestore::replace_text(&source, sid, oid, 0..6, "Xab🦀cYd").unwrap(); |
| 234 | let mut server = Server::new(&remote); | 224 | let mut server = Server::new(&remote); |
| 235 | let outcome = cache.sync_once(&mut server).unwrap().unwrap(); | 225 | let outcome = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 236 | assert_eq!(outcome.0, id); | 226 | assert_eq!(outcome.0, id); |
| 237 | assert!(matches!(outcome.1, EditStatus::Published { .. })); | 227 | assert!(matches!(outcome.1, EditStatus::Published { .. })); |
| 238 | assert_eq!(text(&server.durable).2, "Xab🐈cYd"); | 228 | assert_eq!(text(&server.durable).2, "Xab🐈cYd"); |
| ... | @@ -241,7 +231,7 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() { | ... | @@ -241,7 +231,7 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() { |
| 241 | drop(cache); | 231 | drop(cache); |
| 242 | let cache = Replica::open(&path).unwrap(); | 232 | let cache = Replica::open(&path).unwrap(); |
| 243 | assert_eq!(cache.status(id).unwrap(), Some(outcome.1.clone())); | 233 | assert_eq!(cache.status(id).unwrap(), Some(outcome.1.clone())); |
| 244 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | 234 | assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); |
| 245 | assert_eq!(server.publications, 1); | 235 | assert_eq!(server.publications, 1); |
| 246 | assert_eq!(cache.status(id + 1).unwrap(), None); | 236 | assert_eq!(cache.status(id + 1).unwrap(), None); |
| 247 | let next = save(&cache, oid, 0..0, "Later ").unwrap(); | 237 | let next = save(&cache, oid, 0..0, "Later ").unwrap(); |
| ... | @@ -260,13 +250,13 @@ fn overlapping_changes_preserve_both_images_and_survive_restart() { | ... | @@ -260,13 +250,13 @@ fn overlapping_changes_preserve_both_images_and_survive_restart() { |
| 260 | let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); | 250 | let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); |
| 261 | let mut server = Server::new(&remote); | 251 | let mut server = Server::new(&remote); |
| 262 | assert_eq!( | 252 | assert_eq!( |
| 263 | cache.sync_once(&mut server).unwrap(), | 253 | cache.sync_once(&mut server).unwrap().edit, |
| 264 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 254 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 265 | ); | 255 | ); |
| 266 | assert_eq!(server.publications, 0); | 256 | assert_eq!(server.publications, 0); |
| 267 | drop(cache); | 257 | drop(cache); |
| 268 | let cache = Replica::open(&path).unwrap(); | 258 | let cache = Replica::open(&path).unwrap(); |
| 269 | assert_eq!(cache.snapshot().unwrap(), local); | 259 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 270 | assert_eq!(cache.remote_snapshot().unwrap(), remote); | 260 | assert_eq!(cache.remote_snapshot().unwrap(), remote); |
| 271 | assert_eq!(cache.pending().unwrap().len(), 1); | 261 | assert_eq!(cache.pending().unwrap().len(), 1); |
| 272 | assert_eq!( | 262 | assert_eq!( |
| ... | @@ -298,7 +288,7 @@ fn lost_replies_and_process_termination_never_blindly_replay_an_attempt() { | ... | @@ -298,7 +288,7 @@ fn lost_replies_and_process_termination_never_blindly_replay_an_attempt() { |
| 298 | assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. })); | 288 | assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. })); |
| 299 | drop(cache); | 289 | drop(cache); |
| 300 | let cache = Replica::open(&path).unwrap(); | 290 | let cache = Replica::open(&path).unwrap(); |
| 301 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | 291 | let result = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 302 | if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) { | 292 | if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) { |
| 303 | assert!(matches!(result.1, EditStatus::Published { .. })); | 293 | assert!(matches!(result.1, EditStatus::Published { .. })); |
| 304 | assert_eq!(text(&server.durable).2, "Once abc"); | 294 | assert_eq!(text(&server.durable).2, "Once abc"); |
| ... | @@ -333,7 +323,7 @@ fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() { | ... | @@ -333,7 +323,7 @@ fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() { |
| 333 | Some(EditStatus::AwaitingConfirmation { .. }) | 323 | Some(EditStatus::AwaitingConfirmation { .. }) |
| 334 | )); | 324 | )); |
| 335 | assert!(matches!( | 325 | assert!(matches!( |
| 336 | cache.sync_once(&mut server).unwrap(), | 326 | cache.sync_once(&mut server).unwrap().edit, |
| 337 | Some((_, EditStatus::Published { .. })) | 327 | Some((_, EditStatus::Published { .. })) |
| 338 | )); | 328 | )); |
| 339 | assert_eq!(server.publications, 1); | 329 | assert_eq!(server.publications, 1); |
| ... | @@ -383,7 +373,7 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts( | ... | @@ -383,7 +373,7 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts( |
| 383 | if matches!(fault, Fault::Before) { | 373 | if matches!(fault, Fault::Before) { |
| 384 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | 374 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); |
| 385 | assert!(matches!( | 375 | assert!(matches!( |
| 386 | cache.sync_once(&mut server).unwrap(), | 376 | cache.sync_once(&mut server).unwrap().edit, |
| 387 | Some((_, EditStatus::Published { .. })) | 377 | Some((_, EditStatus::Published { .. })) |
| 388 | )); | 378 | )); |
| 389 | assert_eq!(server.publications, 2); | 379 | assert_eq!(server.publications, 2); |
| ... | @@ -392,7 +382,7 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts( | ... | @@ -392,7 +382,7 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts( |
| 392 | cache.status(id).unwrap(), | 382 | cache.status(id).unwrap(), |
| 393 | Some(EditStatus::Published { .. }) | 383 | Some(EditStatus::Published { .. }) |
| 394 | )); | 384 | )); |
| 395 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | 385 | assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); |
| 396 | assert_eq!(server.publications, 1); | 386 | assert_eq!(server.publications, 1); |
| 397 | } | 387 | } |
| 398 | assert_eq!(text(&server.durable).2, "Once abc"); | 388 | assert_eq!(text(&server.durable).2, "Once abc"); |
| ... | @@ -401,7 +391,10 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts( | ... | @@ -401,7 +391,10 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts( |
| 401 | 391 | ||
| 402 | #[test] | 392 | #[test] |
| 403 | fn database_failures_before_and_after_publication_preserve_recovery_state() { | 393 | fn database_failures_before_and_after_publication_preserve_recovery_state() { |
| 404 | for table in ["attempt", "receipts"] { | 394 | for (table, event) in [ |
| 395 | ("attempt", "UPDATE OF attempted ON batches"), | ||
| 396 | ("receipts", "INSERT ON receipts"), | ||
| 397 | ] { | ||
| 405 | let dir = tempfile::tempdir().unwrap(); | 398 | let dir = tempfile::tempdir().unwrap(); |
| 406 | let path = dir.path().join("cache.sqlite"); | 399 | let path = dir.path().join("cache.sqlite"); |
| 407 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | 400 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); |
| ... | @@ -410,7 +403,7 @@ fn database_failures_before_and_after_publication_preserve_recovery_state() { | ... | @@ -410,7 +403,7 @@ fn database_failures_before_and_after_publication_preserve_recovery_state() { |
| 410 | let id = save(&cache, oid, 0..0, "Once ").unwrap(); | 403 | let id = save(&cache, oid, 0..0, "Once ").unwrap(); |
| 411 | drop(cache); | 404 | drop(cache); |
| 412 | let db = rusqlite::Connection::open(&path).unwrap(); | 405 | let db = rusqlite::Connection::open(&path).unwrap(); |
| 413 | db.execute_batch(&format!("CREATE TRIGGER interrupted BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT,'Injected cache failure'); END;")).unwrap(); | 406 | db.execute_batch(&format!("CREATE TRIGGER interrupted BEFORE {event} BEGIN SELECT RAISE(ABORT,'Injected cache failure'); END;")).unwrap(); |
| 414 | drop(db); | 407 | drop(db); |
| 415 | let cache = Replica::open(&path).unwrap(); | 408 | let cache = Replica::open(&path).unwrap(); |
| 416 | let mut server = Server::new(&source); | 409 | let mut server = Server::new(&source); |
| ... | @@ -426,7 +419,7 @@ fn database_failures_before_and_after_publication_preserve_recovery_state() { | ... | @@ -426,7 +419,7 @@ fn database_failures_before_and_after_publication_preserve_recovery_state() { |
| 426 | drop(db); | 419 | drop(db); |
| 427 | let cache = Replica::open(&path).unwrap(); | 420 | let cache = Replica::open(&path).unwrap(); |
| 428 | assert!(matches!( | 421 | assert!(matches!( |
| 429 | cache.sync_once(&mut server).unwrap(), | 422 | cache.sync_once(&mut server).unwrap().edit, |
| 430 | Some((_, EditStatus::Published { .. })) | 423 | Some((_, EditStatus::Published { .. })) |
| 431 | )); | 424 | )); |
| 432 | assert!(matches!( | 425 | assert!(matches!( |
| ... | @@ -474,7 +467,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio | ... | @@ -474,7 +467,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio |
| 474 | for phase in ["read", "publish", "confirm"] { | 467 | for phase in ["read", "publish", "confirm"] { |
| 475 | let dir = tempfile::tempdir().unwrap(); | 468 | let dir = tempfile::tempdir().unwrap(); |
| 476 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | 469 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); |
| 477 | let (_, oid, _) = text(&source); | 470 | let (sid, oid, _) = text(&source); |
| 478 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | 471 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); |
| 479 | let first = save(&cache, oid, 0..0, "First ").unwrap(); | 472 | let first = save(&cache, oid, 0..0, "First ").unwrap(); |
| 480 | let mut server = Server::new(&source); | 473 | let mut server = Server::new(&source); |
| ... | @@ -493,9 +486,17 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio | ... | @@ -493,9 +486,17 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio |
| 493 | let mut server = std::thread::scope(|scope| { | 486 | let mut server = std::thread::scope(|scope| { |
| 494 | let running = scope.spawn(|| { | 487 | let running = scope.spawn(|| { |
| 495 | let result = cache.sync_once(&mut paused); | 488 | let result = cache.sync_once(&mut paused); |
| 496 | assert!( | 489 | assert!(matches!( |
| 497 | matches!(result, Ok(Some((id, EditStatus::Published { .. }))) if id == first) | 490 | result, |
| 498 | ); | 491 | Ok(notebook::Synced { |
| 492 | edit: Some((_, EditStatus::Published { .. })), | ||
| 493 | .. | ||
| 494 | }) | ||
| 495 | )); | ||
| 496 | assert!(matches!( | ||
| 497 | cache.status(first).unwrap(), | ||
| 498 | Some(EditStatus::Published { .. }) | ||
| 499 | )); | ||
| 499 | paused.server | 500 | paused.server |
| 500 | }); | 501 | }); |
| 501 | entered_rx | 502 | entered_rx |
| ... | @@ -504,26 +505,31 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio | ... | @@ -504,26 +505,31 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio |
| 504 | assert!( | 505 | assert!( |
| 505 | matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock) | 506 | matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock) |
| 506 | ); | 507 | ); |
| 507 | let reviewed = edited(&source, oid, 0..0, "Reviewed "); | ||
| 508 | assert!( | 508 | assert!( |
| 509 | matches!(cache.review_page(first, &[], &[], &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::WouldBlock) | 509 | matches!(cache.resolve(first, Resolution::Theirs), Err(Error::Io(error)) if error.kind() == io::ErrorKind::WouldBlock) |
| 510 | ); | 510 | ); |
| 511 | let started = std::time::Instant::now(); | 511 | let started = std::time::Instant::now(); |
| 512 | let handles: Vec<_> = (0..12) | 512 | let handles: Vec<_> = (0..12) |
| 513 | .map(|writer| { | 513 | .map(|writer| { |
| 514 | let cache = &cache; | 514 | let cache = &cache; |
| 515 | scope.spawn(move || { | 515 | scope.spawn(move || { |
| 516 | loop { | 516 | // Ops, unlike models, commute with the other writers' edits. |
| 517 | let marker = format!("[{writer}] "); | 517 | cache |
| 518 | match model_ops::save(cache, oid, |page| { | 518 | .apply( |
| 519 | model_ops::replace_text(page, oid, 0..0, &marker) | 519 | model_ops::AUTHOR, |
| 520 | }) { | 520 | onestore::op::Edit { |
| 521 | Ok(Some(id)) => break id, | 521 | at: model_ops::now(), |
| 522 | Err(Error::Io(error)) | 522 | ops: vec![onestore::op::Op::Page { |
| 523 | if error.kind() == io::ErrorKind::ResourceBusy => {} | 523 | space: sid, |
| 524 | other => panic!("Unexpected local outcome {other:?}"), | 524 | op: onestore::op::PageOp::Text { |
| 525 | } | 525 | text: oid, |
| 526 | } | 526 | range: 0..0, |
| 527 | with: format!("[{writer}] "), | ||
| 528 | }, | ||
| 529 | }], | ||
| 530 | }, | ||
| 531 | ) | ||
| 532 | .unwrap() | ||
| 527 | }) | 533 | }) |
| 528 | }) | 534 | }) |
| 529 | .collect(); | 535 | .collect(); |
| ... | @@ -531,12 +537,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio | ... | @@ -531,12 +537,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio |
| 531 | .into_iter() | 537 | .into_iter() |
| 532 | .map(|handle| handle.join().unwrap()) | 538 | .map(|handle| handle.join().unwrap()) |
| 533 | .collect(); | 539 | .collect(); |
| 534 | assert_eq!(ids.len(), 1, "saves to one page coalesce"); | 540 | assert_eq!(ids.len(), 12, "every local edit is its own"); |
| 535 | assert_eq!( | ||
| 536 | ids.contains(&first), | ||
| 537 | phase == "read", | ||
| 538 | "saves coalesce into the head only until a step selects it" | ||
| 539 | ); | ||
| 540 | assert!( | 541 | assert!( |
| 541 | started.elapsed() < std::time::Duration::from_secs(2), | 542 | started.elapsed() < std::time::Duration::from_secs(2), |
| 542 | "Local edits waited for remote {phase}" | 543 | "Local edits waited for remote {phase}" |
| ... | @@ -544,14 +545,18 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio | ... | @@ -544,14 +545,18 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio |
| 544 | resume_tx.send(()).unwrap(); | 545 | resume_tx.send(()).unwrap(); |
| 545 | running.join().unwrap() | 546 | running.join().unwrap() |
| 546 | }); | 547 | }); |
| 547 | assert_eq!(cache.pending().unwrap().len(), usize::from(phase != "read")); | 548 | // Edits arriving while a batch publishes form the next batch. |
| 549 | assert_eq!( | ||
| 550 | cache.pending().unwrap().len(), | ||
| 551 | if phase == "read" { 0 } else { 12 } | ||
| 552 | ); | ||
| 548 | let expected = text(&cache.snapshot().unwrap()).2; | 553 | let expected = text(&cache.snapshot().unwrap()).2; |
| 549 | for writer in 0..12 { | 554 | for writer in 0..12 { |
| 550 | assert_eq!(expected.matches(&format!("[{writer}] ")).count(), 1); | 555 | assert_eq!(expected.matches(&format!("[{writer}] ")).count(), 1); |
| 551 | } | 556 | } |
| 552 | while !cache.pending().unwrap().is_empty() { | 557 | while !cache.pending().unwrap().is_empty() { |
| 553 | assert!(matches!( | 558 | assert!(matches!( |
| 554 | cache.sync_once(&mut server).unwrap(), | 559 | cache.sync_once(&mut server).unwrap().edit, |
| 555 | Some((_, EditStatus::Published { .. })) | 560 | Some((_, EditStatus::Published { .. })) |
| 556 | )); | 561 | )); |
| 557 | } | 562 | } |
| ... | @@ -573,136 +578,155 @@ fn unrelated_remote_files_never_replace_a_local_cache() { | ... | @@ -573,136 +578,155 @@ fn unrelated_remote_files_never_replace_a_local_cache() { |
| 573 | if pending { | 578 | if pending { |
| 574 | save(&cache, oid, 0..0, "Local ").unwrap(); | 579 | save(&cache, oid, 0..0, "Local ").unwrap(); |
| 575 | } | 580 | } |
| 576 | let before = cache.snapshot().unwrap(); | 581 | let before = pages(&cache.snapshot().unwrap()); |
| 577 | assert!( | 582 | assert!( |
| 578 | matches!(cache.sync_once(&mut server),Err(Error::Io(error)) if error.kind()==io::ErrorKind::InvalidInput) | 583 | matches!(cache.sync_once(&mut server),Err(Error::Io(error)) if error.kind()==io::ErrorKind::InvalidInput) |
| 579 | ); | 584 | ); |
| 580 | assert_eq!(cache.snapshot().unwrap(), before); | 585 | assert_eq!(pages(&cache.snapshot().unwrap()), before); |
| 581 | assert_eq!(cache.remote_snapshot().unwrap(), source); | 586 | assert_eq!(cache.remote_snapshot().unwrap(), source); |
| 582 | assert_eq!(server.publications, 0); | 587 | assert_eq!(server.publications, 0); |
| 583 | } | 588 | } |
| 584 | } | 589 | } |
| 585 | 590 | ||
| 591 | /// The text object's content in an image. | ||
| 592 | fn content(bytes: &[u8], text: ExGuid) -> String { | ||
| 593 | let (_, page) = model_ops::locate(bytes, text); | ||
| 594 | model_ops::paragraph_with(&page, text) | ||
| 595 | .unwrap() | ||
| 596 | .text() | ||
| 597 | .unwrap() | ||
| 598 | .text | ||
| 599 | .text() | ||
| 600 | .to_owned() | ||
| 601 | } | ||
| 602 | |||
| 586 | #[test] | 603 | #[test] |
| 587 | fn reviewing_a_conflict_preserves_the_dependent_save_and_survives_reopen() { | 604 | fn a_conflict_holds_later_edits_until_resolved_either_way_and_survives_reopen() { |
| 588 | let dir = tempfile::tempdir().unwrap(); | 605 | for resolution in [Resolution::Mine, Resolution::Theirs] { |
| 589 | let path = dir.path().join("cache.sqlite"); | 606 | let dir = tempfile::tempdir().unwrap(); |
| 590 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | 607 | let path = dir.path().join("cache.sqlite"); |
| 591 | let (sid, oid, _) = text(&source); | 608 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); |
| 592 | let cache = Replica::create(&path, &source).unwrap(); | 609 | let (sid, oid, _) = text(&source); |
| 593 | let first = save(&cache, oid, 1..2, "L").unwrap(); | 610 | let cache = Replica::create(&path, &source).unwrap(); |
| 594 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "aRcZ").unwrap(); | 611 | let first = save(&cache, oid, 1..2, "L").unwrap(); |
| 595 | let mut server = Server::new(&remote); | 612 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "aRcZ").unwrap(); |
| 596 | assert_eq!( | 613 | let mut server = Server::new(&remote); |
| 597 | cache.sync_once(&mut server).unwrap(), | 614 | let conflict = EditStatus::Conflict(ConflictKind::ContentChanged); |
| 598 | Some((first, EditStatus::Conflict(ConflictKind::ContentChanged))) | 615 | assert_eq!( |
| 599 | ); | 616 | cache.sync_once(&mut server).unwrap().edit, |
| 600 | let mut dependent = None; | 617 | Some((first, conflict.clone())) |
| 601 | for n in 0..12 { | 618 | ); |
| 602 | dependent = save(&cache, oid, 0..0, &format!("[{n}] ")); | 619 | let mut dependent = None; |
| 603 | } | 620 | for n in 0..12 { |
| 604 | let dependent = dependent.unwrap(); | 621 | dependent = save(&cache, oid, 0..0, &format!("[{n}] ")); |
| 605 | assert_ne!(dependent, first, "a conflicted save is never rewritten"); | 622 | } |
| 606 | let local = cache.snapshot().unwrap(); | 623 | let dependent = dependent.unwrap(); |
| 607 | let pending = cache.pending().unwrap(); | 624 | assert_ne!(dependent, first); |
| 608 | assert_eq!(pending.len(), 2); | 625 | assert_eq!(cache.status(dependent).unwrap(), Some(conflict.clone())); |
| 609 | model_ops::review(&cache, first, oid, |page| { | 626 | let local = content(&cache.snapshot().unwrap(), oid); |
| 610 | model_ops::replace_text(page, oid, 1..2, "L") | 627 | assert!(local.ends_with("[0] aLc"), "{local}"); |
| 611 | }) | 628 | let pending = cache.pending().unwrap(); |
| 612 | .unwrap(); | 629 | assert_eq!(pending.len(), 13); |
| 613 | assert_eq!(cache.snapshot().unwrap(), local); | 630 | assert_eq!( |
| 614 | assert_eq!(cache.remote_snapshot().unwrap(), remote); | 631 | cache.sync_once(&mut server).unwrap().edit, |
| 615 | let reviewed = cache.pending().unwrap(); | 632 | Some((dependent, conflict.clone())), |
| 616 | assert_eq!(reviewed[1..], pending[1..]); | 633 | "an unchanged remote leaves the conflict as it was" |
| 617 | assert_eq!(reviewed[0].id, first); | 634 | ); |
| 618 | let notebook::Operation::Page(intent) = &reviewed[0].operation else { | 635 | assert_eq!(server.publications, 0); |
| 619 | panic!() | 636 | drop(cache); |
| 620 | }; | 637 | let cache = Replica::open(&path).unwrap(); |
| 621 | assert_eq!( | 638 | assert_eq!(cache.pending().unwrap(), pending); |
| 622 | intent.text_change(), | 639 | assert_eq!(content(&cache.snapshot().unwrap(), oid), local); |
| 623 | Some((oid, "aRcZ".into(), 1..2, "L".into())) | 640 | assert_eq!( |
| 624 | ); | 641 | cache.conflict().unwrap(), |
| 625 | assert_eq!(intent.author, model_ops::AUTHOR); | 642 | Some(notebook::Conflict { |
| 626 | assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending)); | 643 | id: dependent, |
| 627 | drop(cache); | 644 | space: sid, |
| 628 | let cache = Replica::open(&path).unwrap(); | 645 | kind: ConflictKind::ContentChanged |
| 629 | assert_eq!(cache.pending().unwrap(), reviewed); | 646 | }) |
| 630 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 631 | for intent in &pending { | ||
| 632 | assert!( | ||
| 633 | matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == intent.id) | ||
| 634 | ); | 647 | ); |
| 648 | cache.resolve(first, resolution).unwrap(); | ||
| 649 | assert_eq!(cache.conflict().unwrap(), None); | ||
| 650 | assert_eq!(cache.remote_snapshot().unwrap(), remote); | ||
| 651 | let expected = match resolution { | ||
| 652 | Resolution::Mine => local.clone(), | ||
| 653 | Resolution::Theirs => "aRcZ".to_owned(), | ||
| 654 | }; | ||
| 655 | assert_eq!(content(&cache.snapshot().unwrap(), oid), expected); | ||
| 656 | drop(cache); | ||
| 657 | let cache = Replica::open(&path).unwrap(); | ||
| 658 | match resolution { | ||
| 659 | Resolution::Mine => { | ||
| 660 | assert!(matches!( | ||
| 661 | cache.sync_once(&mut server).unwrap().edit, | ||
| 662 | Some((_, EditStatus::Published { .. })) | ||
| 663 | )); | ||
| 664 | assert_eq!(server.publications, 1); | ||
| 665 | for edit in &pending { | ||
| 666 | assert!(matches!( | ||
| 667 | cache.status(edit.id).unwrap(), | ||
| 668 | None | Some(EditStatus::Published { .. }) | ||
| 669 | )); | ||
| 670 | } | ||
| 671 | assert!(matches!( | ||
| 672 | cache.status(first).unwrap(), | ||
| 673 | Some(EditStatus::Published { .. }) | ||
| 674 | )); | ||
| 675 | } | ||
| 676 | Resolution::Theirs => { | ||
| 677 | // The emptied edits are acknowledged once the remote image is flushed. | ||
| 678 | assert!(matches!( | ||
| 679 | cache.sync_once(&mut server).unwrap().edit, | ||
| 680 | Some((id, EditStatus::Published { .. })) if id == dependent | ||
| 681 | )); | ||
| 682 | assert_eq!((server.publications, server.confirmations), (0, 1)); | ||
| 683 | assert!(matches!( | ||
| 684 | cache.status(first).unwrap(), | ||
| 685 | Some(EditStatus::Published { .. }) | ||
| 686 | )); | ||
| 687 | } | ||
| 688 | } | ||
| 689 | assert_eq!(content(&server.durable, oid), expected); | ||
| 690 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&server.durable)); | ||
| 691 | assert!(cache.pending().unwrap().is_empty()); | ||
| 635 | } | 692 | } |
| 636 | assert_eq!(server.publications, 2); | ||
| 637 | assert_eq!(text(&server.durable).2, text(&local).2 + "Z"); | ||
| 638 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 639 | assert!(cache.pending().unwrap().is_empty()); | ||
| 640 | } | 693 | } |
| 641 | 694 | ||
| 642 | #[test] | 695 | #[test] |
| 643 | fn conflict_review_rejects_stale_images_and_nonconflicting_states() { | 696 | fn resolution_refuses_edits_that_are_not_in_conflict() { |
| 644 | let dir = tempfile::tempdir().unwrap(); | 697 | let dir = tempfile::tempdir().unwrap(); |
| 645 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | 698 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); |
| 646 | let (sid, oid, _) = text(&source); | 699 | let (sid, oid, _) = text(&source); |
| 647 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | 700 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); |
| 648 | let id = save(&cache, oid, 1..2, "L").unwrap(); | 701 | let id = save(&cache, oid, 1..2, "L").unwrap(); |
| 649 | let local = cache.snapshot().unwrap(); | 702 | let refused = |cache: &Replica, id| matches!(cache.resolve(id, Resolution::Mine), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput); |
| 650 | let pending_review = edited(&source, oid, 1..2, "L"); | 703 | assert!(refused(&cache, id)); |
| 651 | assert!( | ||
| 652 | matches!(cache.review_page(id, &local, &source, &pending_review), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) | ||
| 653 | ); | ||
| 654 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "🦀Rc").unwrap(); | 704 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "🦀Rc").unwrap(); |
| 655 | let mut server = Server::new(&remote); | 705 | let mut server = Server::new(&remote); |
| 656 | assert!(matches!( | 706 | assert!(matches!( |
| 657 | cache.sync_once(&mut server).unwrap(), | 707 | cache.sync_once(&mut server).unwrap().edit, |
| 658 | Some((_, EditStatus::Conflict(_))) | 708 | Some((_, EditStatus::Conflict(_))) |
| 659 | )); | 709 | )); |
| 660 | let pending = cache.pending().unwrap(); | 710 | let pending = cache.pending().unwrap(); |
| 661 | let reviewed = edited(&remote, oid, 2..3, "L"); | 711 | assert!(refused(&cache, id + 1)); |
| 662 | assert!( | ||
| 663 | matches!(cache.review_page(id + 1, &local, &remote, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) | ||
| 664 | ); | ||
| 665 | assert_eq!(cache.pending().unwrap(), pending); | 712 | assert_eq!(cache.pending().unwrap(), pending); |
| 666 | save(&cache, oid, 0..0, "Later ").unwrap(); | 713 | cache.resolve(id, Resolution::Mine).unwrap(); |
| 667 | let changed = cache.snapshot().unwrap(); | ||
| 668 | assert!( | ||
| 669 | matches!(cache.review_page(id, &local, &remote, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy) | ||
| 670 | ); | ||
| 671 | let new_remote = onestore::replace_text(&remote, sid, oid, 2..3, "Q").unwrap(); | ||
| 672 | server.visible = new_remote.clone(); | ||
| 673 | server.durable = new_remote; | ||
| 674 | assert!(matches!( | ||
| 675 | cache.sync_once(&mut server).unwrap(), | ||
| 676 | Some((_, EditStatus::Conflict(_))) | ||
| 677 | )); | ||
| 678 | assert!( | ||
| 679 | matches!(cache.review_page(id, &changed, &remote, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy) | ||
| 680 | ); | ||
| 681 | assert_eq!(cache.snapshot().unwrap(), changed); | ||
| 682 | assert_eq!(cache.pending().unwrap()[0], pending[0]); | ||
| 683 | assert_eq!(server.publications, 0); | ||
| 684 | |||
| 685 | let current = cache.remote_snapshot().unwrap(); | ||
| 686 | let reviewed = edited(&current, oid, 2..3, "L"); | ||
| 687 | cache | ||
| 688 | .review_page(id, &changed, &current, &reviewed) | ||
| 689 | .unwrap(); | ||
| 690 | server.fault = Fault::UnknownBefore; | 714 | server.fault = Fault::UnknownBefore; |
| 691 | assert!( | 715 | assert!( |
| 692 | matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Unknown) | 716 | matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Unknown) |
| 693 | ); | 717 | ); |
| 694 | let attempted = cache.status(id).unwrap(); | 718 | let attempted = cache.status(id).unwrap(); |
| 695 | let pending = cache.pending().unwrap(); | 719 | assert!(matches!( |
| 696 | assert!( | 720 | attempted, |
| 697 | matches!(cache.review_page(id, &changed, &current, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) | 721 | Some(EditStatus::AwaitingConfirmation { .. }) |
| 698 | ); | 722 | )); |
| 723 | assert!(refused(&cache, id)); | ||
| 699 | assert_eq!(cache.status(id).unwrap(), attempted); | 724 | assert_eq!(cache.status(id).unwrap(), attempted); |
| 700 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 701 | assert_eq!(server.publications, 1); | 725 | assert_eq!(server.publications, 1); |
| 702 | } | 726 | } |
| 703 | 727 | ||
| 704 | #[test] | 728 | #[test] |
| 705 | fn failure_between_review_and_conflict_clear_rolls_back_the_entire_resolution() { | 729 | fn a_failed_resolution_leaves_the_conflict_as_it_was() { |
| 706 | let dir = tempfile::tempdir().unwrap(); | 730 | let dir = tempfile::tempdir().unwrap(); |
| 707 | let path = dir.path().join("cache.sqlite"); | 731 | let path = dir.path().join("cache.sqlite"); |
| 708 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | 732 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); |
| ... | @@ -712,30 +736,29 @@ fn failure_between_review_and_conflict_clear_rolls_back_the_entire_resolution() | ... | @@ -712,30 +736,29 @@ fn failure_between_review_and_conflict_clear_rolls_back_the_entire_resolution() |
| 712 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "XaRc").unwrap(); | 736 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "XaRc").unwrap(); |
| 713 | let mut server = Server::new(&remote); | 737 | let mut server = Server::new(&remote); |
| 714 | cache.sync_once(&mut server).unwrap(); | 738 | cache.sync_once(&mut server).unwrap(); |
| 715 | let local = cache.snapshot().unwrap(); | 739 | let local = pages(&cache.snapshot().unwrap()); |
| 716 | let pending = cache.pending().unwrap(); | 740 | let pending = cache.pending().unwrap(); |
| 717 | let status = cache.status(id).unwrap(); | 741 | let status = cache.status(id).unwrap(); |
| 718 | drop(cache); | 742 | drop(cache); |
| 719 | let connection = rusqlite::Connection::open(&path).unwrap(); | 743 | let connection = rusqlite::Connection::open(&path).unwrap(); |
| 720 | connection.execute_batch("CREATE TRIGGER fail_clear BEFORE DELETE ON conflicts BEGIN SELECT RAISE(ABORT, 'test conflict clear failure'); END;").unwrap(); | 744 | connection.execute_batch("CREATE TRIGGER fail_clear BEFORE DELETE ON batches BEGIN SELECT RAISE(ABORT, 'test resolution failure'); END;").unwrap(); |
| 721 | drop(connection); | 745 | drop(connection); |
| 722 | let cache = Replica::open(&path).unwrap(); | 746 | let cache = Replica::open(&path).unwrap(); |
| 723 | let reviewed = edited(&remote, oid, 2..3, "L"); | ||
| 724 | assert!(matches!( | 747 | assert!(matches!( |
| 725 | cache.review_page(id, &local, &remote, &reviewed), | 748 | cache.resolve(id, Resolution::Mine), |
| 726 | Err(Error::Database(_)) | 749 | Err(Error::Database(_)) |
| 727 | )); | 750 | )); |
| 728 | drop(cache); | 751 | drop(cache); |
| 729 | let cache = Replica::open(&path).unwrap(); | 752 | let cache = Replica::open(&path).unwrap(); |
| 730 | assert_eq!(cache.pending().unwrap(), pending); | 753 | assert_eq!(cache.pending().unwrap(), pending); |
| 731 | assert_eq!(cache.status(id).unwrap(), status); | 754 | assert_eq!(cache.status(id).unwrap(), status); |
| 732 | assert_eq!(cache.snapshot().unwrap(), local); | 755 | assert_eq!(pages(&cache.snapshot().unwrap()), local); |
| 733 | assert_eq!(cache.remote_snapshot().unwrap(), remote); | 756 | assert_eq!(cache.remote_snapshot().unwrap(), remote); |
| 734 | assert_eq!(server.publications, 0); | 757 | assert_eq!(server.publications, 0); |
| 735 | } | 758 | } |
| 736 | 759 | ||
| 737 | #[test] | 760 | #[test] |
| 738 | fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() { | 761 | fn seeded_conflicts_resolve_to_the_remote_text_and_later_edits_publish_once() { |
| 739 | let dir = tempfile::tempdir().unwrap(); | 762 | let dir = tempfile::tempdir().unwrap(); |
| 740 | let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect(); | 763 | let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect(); |
| 741 | let mut seed = 911_u64; | 764 | let mut seed = 911_u64; |
| ... | @@ -757,8 +780,7 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() { | ... | @@ -757,8 +780,7 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() { |
| 757 | let (sid, oid, _) = text(&source); | 780 | let (sid, oid, _) = text(&source); |
| 758 | let cache = Replica::create(dir.path().join(format!("{case}.sqlite")), &source).unwrap(); | 781 | let cache = Replica::create(dir.path().join(format!("{case}.sqlite")), &source).unwrap(); |
| 759 | let id = save(&cache, oid, start..end, "λ🦊μ").unwrap(); | 782 | let id = save(&cache, oid, start..end, "λ🦊μ").unwrap(); |
| 760 | let dependent = save(&cache, oid, start + 1..start + 3, "🐕").unwrap(); | 783 | save(&cache, oid, start + 1..start + 3, "🐕").unwrap(); |
| 761 | assert_eq!(dependent, id, "case {case}, seed {seed}"); | ||
| 762 | let mut remote_text = original.clone(); | 784 | let mut remote_text = original.clone(); |
| 763 | remote_text.splice(at..at + 1, "Ω🐈π".chars()); | 785 | remote_text.splice(at..at + 1, "Ω🐈π".chars()); |
| 764 | let remote_text = prefix.clone() + &remote_text.iter().collect::<String>() + &suffix; | 786 | let remote_text = prefix.clone() + &remote_text.iter().collect::<String>() + &suffix; |
| ... | @@ -771,18 +793,21 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() { | ... | @@ -771,18 +793,21 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() { |
| 771 | ) | 793 | ) |
| 772 | .unwrap(); | 794 | .unwrap(); |
| 773 | let mut server = Server::new(&remote); | 795 | let mut server = Server::new(&remote); |
| 774 | assert_eq!( | 796 | assert!( |
| 775 | cache.sync_once(&mut server).unwrap(), | 797 | matches!( |
| 776 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))), | 798 | cache.sync_once(&mut server).unwrap().edit, |
| 799 | Some((_, EditStatus::Conflict(ConflictKind::ContentChanged))) | ||
| 800 | ), | ||
| 777 | "case {case}, seed {seed}" | 801 | "case {case}, seed {seed}" |
| 778 | ); | 802 | ); |
| 779 | let at_remote = start + prefix.len() as u32; | 803 | let at_remote = start + prefix.len() as u32; |
| 780 | model_ops::review(&cache, id, oid, |page| { | 804 | let reviewed = model_ops::review(&cache, id, oid, |page| { |
| 781 | model_ops::replace_text(page, oid, at_remote..at_remote + 4, "λ🐕μ") | 805 | model_ops::replace_text(page, oid, at_remote..at_remote + 4, "λ🐕μ") |
| 782 | }) | 806 | }) |
| 807 | .unwrap() | ||
| 783 | .unwrap(); | 808 | .unwrap(); |
| 784 | assert!( | 809 | assert!( |
| 785 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id), | 810 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == reviewed), |
| 786 | "case {case}, seed {seed}" | 811 | "case {case}, seed {seed}" |
| 787 | ); | 812 | ); |
| 788 | let mut expected = original.clone(); | 813 | let mut expected = original.clone(); |
| ... | @@ -799,7 +824,7 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() { | ... | @@ -799,7 +824,7 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() { |
| 799 | } | 824 | } |
| 800 | 825 | ||
| 801 | #[test] | 826 | #[test] |
| 802 | fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() { | 827 | fn remote_changes_after_resolution_are_never_overwritten() { |
| 803 | struct ChangedAfterRead { | 828 | struct ChangedAfterRead { |
| 804 | server: Server, | 829 | server: Server, |
| 805 | change: Option<Vec<u8>>, | 830 | change: Option<Vec<u8>>, |
| ... | @@ -826,20 +851,22 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() { | ... | @@ -826,20 +851,22 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() { |
| 826 | let (sid, oid, _) = text(&source); | 851 | let (sid, oid, _) = text(&source); |
| 827 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | 852 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); |
| 828 | let id = save(&cache, oid, 1..2, "L").unwrap(); | 853 | let id = save(&cache, oid, 1..2, "L").unwrap(); |
| 829 | let local = cache.snapshot().unwrap(); | ||
| 830 | let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); | 854 | let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); |
| 831 | let mut remote = ChangedAfterRead { | 855 | let mut remote = ChangedAfterRead { |
| 832 | server: Server::new(&remote), | 856 | server: Server::new(&remote), |
| 833 | change: None, | 857 | change: None, |
| 834 | }; | 858 | }; |
| 835 | assert!(matches!( | 859 | assert!(matches!( |
| 836 | cache.sync_once(&mut remote).unwrap(), | 860 | cache.sync_once(&mut remote).unwrap().edit, |
| 837 | Some((_, EditStatus::Conflict(_))) | 861 | Some((_, EditStatus::Conflict(_))) |
| 838 | )); | 862 | )); |
| 839 | model_ops::review(&cache, id, oid, |page| { | 863 | let id = model_ops::review(&cache, id, oid, |page| { |
| 840 | model_ops::replace_text(page, oid, 1..2, "L") | 864 | model_ops::replace_text(page, oid, 1..2, "L") |
| 841 | }) | 865 | }) |
| 866 | .unwrap() | ||
| 842 | .unwrap(); | 867 | .unwrap(); |
| 868 | let local = content(&cache.snapshot().unwrap(), oid); | ||
| 869 | assert_eq!(local, "aLc"); | ||
| 843 | let changed = onestore::replace_text(&remote.server.visible, sid, oid, 1..2, "Q").unwrap(); | 870 | let changed = onestore::replace_text(&remote.server.visible, sid, oid, 1..2, "Q").unwrap(); |
| 844 | if after_read { | 871 | if after_read { |
| 845 | remote.change = Some(changed.clone()); | 872 | remote.change = Some(changed.clone()); |
| ... | @@ -854,20 +881,19 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() { | ... | @@ -854,20 +881,19 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() { |
| 854 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | 881 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); |
| 855 | } | 882 | } |
| 856 | assert_eq!( | 883 | assert_eq!( |
| 857 | cache.sync_once(&mut remote).unwrap(), | 884 | cache.sync_once(&mut remote).unwrap().edit, |
| 858 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 885 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 859 | ); | 886 | ); |
| 860 | assert_eq!(remote.server.durable, changed); | 887 | assert_eq!(remote.server.durable, changed); |
| 861 | assert_eq!(remote.server.visible, changed); | 888 | assert_eq!(remote.server.visible, changed); |
| 862 | assert_eq!(remote.server.publications, usize::from(after_read)); | 889 | assert_eq!(remote.server.publications, usize::from(after_read)); |
| 863 | assert_eq!(cache.snapshot().unwrap(), local); | 890 | assert_eq!(content(&cache.snapshot().unwrap(), oid), local); |
| 864 | let notebook::Operation::Page(intent) = &cache.pending().unwrap()[0].operation else { | 891 | assert!(cache.pending().unwrap()[0].edit.ops.is_empty()); |
| 865 | panic!() | 892 | assert!(matches!( |
| 866 | }; | 893 | &cache.pending().unwrap()[1].edit.ops[..], |
| 867 | assert_eq!( | 894 | [onestore::op::Op::Page { op: onestore::op::PageOp::Text { range, with, .. }, .. }] |
| 868 | intent.text_change(), | 895 | if *range == (1..2) && with == "L" |
| 869 | Some((oid, "aRc".into(), 1..2, "L".into())) | 896 | )); |
| 870 | ); | ||
| 871 | } | 897 | } |
| 872 | } | 898 | } |
| 873 | 899 | ||
| ... | @@ -880,7 +906,7 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() { | ... | @@ -880,7 +906,7 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() { |
| 880 | let cache = Replica::create(&path, &source).unwrap(); | 906 | let cache = Replica::create(&path, &source).unwrap(); |
| 881 | let mut server = Server::new(&source); | 907 | let mut server = Server::new(&source); |
| 882 | let published = save(&cache, object, 0..0, "Published ").unwrap(); | 908 | let published = save(&cache, object, 0..0, "Published ").unwrap(); |
| 883 | let (_, receipt) = cache.sync_once(&mut server).unwrap().unwrap(); | 909 | let (_, receipt) = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 884 | assert!(matches!(receipt, EditStatus::Published { .. })); | 910 | assert!(matches!(receipt, EditStatus::Published { .. })); |
| 885 | let published_image = cache.snapshot().unwrap(); | 911 | let published_image = cache.snapshot().unwrap(); |
| 886 | cache | 912 | cache |
| ... | @@ -891,7 +917,7 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() { | ... | @@ -891,7 +917,7 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() { |
| 891 | server.visible.clone_from(&source); | 917 | server.visible.clone_from(&source); |
| 892 | server.durable.clone_from(&source); | 918 | server.durable.clone_from(&source); |
| 893 | let cache = Replica::open(&path).unwrap(); | 919 | let cache = Replica::open(&path).unwrap(); |
| 894 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | 920 | assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); |
| 895 | assert_eq!(cache.snapshot().unwrap(), source); | 921 | assert_eq!(cache.snapshot().unwrap(), source); |
| 896 | assert_eq!(cache.remote_snapshot().unwrap(), source); | 922 | assert_eq!(cache.remote_snapshot().unwrap(), source); |
| 897 | assert_eq!(cache.status(published).unwrap(), Some(receipt.clone())); | 923 | assert_eq!(cache.status(published).unwrap(), Some(receipt.clone())); |
| ... | @@ -918,7 +944,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() { | ... | @@ -918,7 +944,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() { |
| 918 | let cache = Replica::create(&path, &source).unwrap(); | 944 | let cache = Replica::create(&path, &source).unwrap(); |
| 919 | let mut server = Server::new(&source); | 945 | let mut server = Server::new(&source); |
| 920 | let published = save(&cache, object, 0..0, "Published ").unwrap(); | 946 | let published = save(&cache, object, 0..0, "Published ").unwrap(); |
| 921 | let (_, receipt) = cache.sync_once(&mut server).unwrap().unwrap(); | 947 | let (_, receipt) = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 922 | let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap(); | 948 | let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap(); |
| 923 | let queued = save(&cache, object, end..end, " Local").unwrap(); | 949 | let queued = save(&cache, object, end..end, " Local").unwrap(); |
| 924 | if uncertain { | 950 | if uncertain { |
| ... | @@ -936,14 +962,14 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() { | ... | @@ -936,14 +962,14 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() { |
| 936 | server.durable.clone_from(&source); | 962 | server.durable.clone_from(&source); |
| 937 | let attempts = server.publications; | 963 | let attempts = server.publications; |
| 938 | let cache = Replica::open(&path).unwrap(); | 964 | let cache = Replica::open(&path).unwrap(); |
| 939 | let (_, status) = cache.sync_once(&mut server).unwrap().unwrap(); | 965 | let (_, status) = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 940 | assert_eq!(cache.status(published).unwrap(), Some(receipt.clone())); | 966 | assert_eq!(cache.status(published).unwrap(), Some(receipt.clone())); |
| 941 | if uncertain { | 967 | if uncertain { |
| 942 | assert!(matches!(status, EditStatus::AwaitingConfirmation { .. })); | 968 | assert!(matches!(status, EditStatus::AwaitingConfirmation { .. })); |
| 943 | assert_eq!(Some(status.clone()), prior_status); | 969 | assert_eq!(Some(status.clone()), prior_status); |
| 944 | assert_eq!(server.publications, attempts); | 970 | assert_eq!(server.publications, attempts); |
| 945 | assert_eq!(server.visible, source); | 971 | assert_eq!(server.visible, source); |
| 946 | assert_eq!(cache.snapshot().unwrap(), local); | 972 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 947 | assert_eq!(cache.pending().unwrap(), pending); | 973 | assert_eq!(cache.pending().unwrap(), pending); |
| 948 | } else { | 974 | } else { |
| 949 | assert!(matches!(status, EditStatus::Published { .. })); | 975 | assert!(matches!(status, EditStatus::Published { .. })); |
| ... | @@ -953,7 +979,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() { | ... | @@ -953,7 +979,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() { |
| 953 | } | 979 | } |
| 954 | let archive = | 980 | let archive = |
| 955 | notebook::Recovery::open(directory.path().join("before-restore.sqlite")).unwrap(); | 981 | notebook::Recovery::open(directory.path().join("before-restore.sqlite")).unwrap(); |
| 956 | assert_eq!(archive.snapshot().unwrap(), local); | 982 | assert_eq!(pages(&archive.snapshot().unwrap()), pages(&local)); |
| 957 | assert_eq!(archive.pending().unwrap(), pending); | 983 | assert_eq!(archive.pending().unwrap(), pending); |
| 958 | assert_eq!(archive.status(queued).unwrap(), prior_status); | 984 | assert_eq!(archive.status(queued).unwrap(), prior_status); |
| 959 | drop(cache); | 985 | drop(cache); |
| ... | @@ -964,7 +990,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() { | ... | @@ -964,7 +990,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() { |
| 964 | } | 990 | } |
| 965 | 991 | ||
| 966 | #[test] | 992 | #[test] |
| 967 | fn restore_conflicts_keep_dependent_work_and_reject_stale_review() { | 993 | fn restore_conflicts_keep_dependent_work_until_mine_publishes_it() { |
| 968 | let directory = tempfile::tempdir().unwrap(); | 994 | let directory = tempfile::tempdir().unwrap(); |
| 969 | let path = directory.path().join("cache.sqlite"); | 995 | let path = directory.path().join("cache.sqlite"); |
| 970 | let source = onestore::create_section("restore.one", "Original", "Fixture").unwrap(); | 996 | let source = onestore::create_section("restore.one", "Original", "Fixture").unwrap(); |
| ... | @@ -972,19 +998,20 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() { | ... | @@ -972,19 +998,20 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() { |
| 972 | let cache = Replica::create(&path, &source).unwrap(); | 998 | let cache = Replica::create(&path, &source).unwrap(); |
| 973 | let mut server = Server::new(&source); | 999 | let mut server = Server::new(&source); |
| 974 | let published = save(&cache, object, 0..0, "Published ").unwrap(); | 1000 | let published = save(&cache, object, 0..0, "Published ").unwrap(); |
| 975 | let (_, receipt) = cache.sync_once(&mut server).unwrap().unwrap(); | 1001 | let (_, receipt) = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 976 | let head = save(&cache, object, 0..9, "Revised").unwrap(); | 1002 | let head = save(&cache, object, 0..9, "Revised").unwrap(); |
| 977 | server.visible.clone_from(&source); | 1003 | server.visible.clone_from(&source); |
| 978 | server.durable.clone_from(&source); | 1004 | server.durable.clone_from(&source); |
| 979 | let conflict = EditStatus::Conflict(ConflictKind::ContentChanged); | 1005 | let conflict = EditStatus::Conflict(ConflictKind::ContentChanged); |
| 980 | assert_eq!( | 1006 | assert_eq!( |
| 981 | cache.sync_once(&mut server).unwrap(), | 1007 | cache.sync_once(&mut server).unwrap().edit, |
| 982 | Some((head, conflict.clone())) | 1008 | Some((head, conflict.clone())) |
| 983 | ); | 1009 | ); |
| 984 | let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap(); | 1010 | let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap(); |
| 985 | let dependent = save(&cache, object, end..end, " Later").unwrap(); | 1011 | let dependent = save(&cache, object, end..end, " Later").unwrap(); |
| 986 | assert_ne!(head, dependent); | 1012 | assert_ne!(head, dependent); |
| 987 | let local = cache.snapshot().unwrap(); | 1013 | let local = text(&cache.snapshot().unwrap()).2; |
| 1014 | assert_eq!(local, "Revised Original Later"); | ||
| 988 | let pending = cache.pending().unwrap(); | 1015 | let pending = cache.pending().unwrap(); |
| 989 | assert_eq!(pending.len(), 2); | 1016 | assert_eq!(pending.len(), 2); |
| 990 | cache | 1017 | cache |
| ... | @@ -993,60 +1020,39 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() { | ... | @@ -993,60 +1020,39 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() { |
| 993 | drop(cache); | 1020 | drop(cache); |
| 994 | let cache = Replica::open(&path).unwrap(); | 1021 | let cache = Replica::open(&path).unwrap(); |
| 995 | assert_eq!(cache.status(head).unwrap(), Some(conflict.clone())); | 1022 | assert_eq!(cache.status(head).unwrap(), Some(conflict.clone())); |
| 1023 | assert_eq!(cache.status(dependent).unwrap(), Some(conflict.clone())); | ||
| 996 | assert_eq!(cache.pending().unwrap(), pending); | 1024 | assert_eq!(cache.pending().unwrap(), pending); |
| 997 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 998 | let reviewed = edited(&source, object, 0..0, "Revised "); | ||
| 999 | let changed = onestore::replace_text(&source, space, object, 8..8, " Remote").unwrap(); | 1025 | let changed = onestore::replace_text(&source, space, object, 8..8, " Remote").unwrap(); |
| 1000 | server.visible.clone_from(&changed); | 1026 | server.visible.clone_from(&changed); |
| 1001 | server.durable.clone_from(&changed); | 1027 | server.durable.clone_from(&changed); |
| 1002 | assert_eq!( | 1028 | assert_eq!( |
| 1003 | cache.sync_once(&mut server).unwrap(), | 1029 | cache.sync_once(&mut server).unwrap().edit, |
| 1004 | Some((head, conflict.clone())) | ||
| 1005 | ); | ||
| 1006 | assert!( | ||
| 1007 | matches!(cache.review_page(head, &local, &source, &reviewed), | ||
| 1008 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy) | ||
| 1009 | ); | ||
| 1010 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 1011 | assert_eq!(server.publications, 1); | ||
| 1012 | let reviewed = edited(&changed, object, 0..0, "Revised "); | ||
| 1013 | cache | ||
| 1014 | .review_page(head, &local, &changed, &reviewed) | ||
| 1015 | .unwrap(); | ||
| 1016 | assert_eq!(cache.pending().unwrap()[1], pending[1]); | ||
| 1017 | drop(cache); | ||
| 1018 | let cache = Replica::open(&path).unwrap(); | ||
| 1019 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 1020 | Some((actual, EditStatus::Published { .. })) if actual == head)); | ||
| 1021 | assert_eq!( | ||
| 1022 | cache.sync_once(&mut server).unwrap(), | ||
| 1023 | Some((dependent, conflict.clone())) | 1030 | Some((dependent, conflict.clone())) |
| 1024 | ); | 1031 | ); |
| 1025 | assert_eq!(server.publications, 2); | 1032 | assert_eq!(cache.remote_snapshot().unwrap(), changed); |
| 1026 | let dependent_local = cache.snapshot().unwrap(); | 1033 | assert_eq!(server.publications, 1); |
| 1027 | let current = cache.remote_snapshot().unwrap(); | 1034 | cache.resolve(head, Resolution::Mine).unwrap(); |
| 1028 | let reviewed = edited(&current, object, 16..16, " Later"); | ||
| 1029 | cache | ||
| 1030 | .review_page(dependent, &dependent_local, &current, &reviewed) | ||
| 1031 | .unwrap(); | ||
| 1032 | drop(cache); | 1035 | drop(cache); |
| 1033 | let cache = Replica::open(&path).unwrap(); | 1036 | let cache = Replica::open(&path).unwrap(); |
| 1034 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | 1037 | assert!(matches!( |
| 1035 | Some((actual, EditStatus::Published { .. })) if actual == dependent)); | 1038 | cache.sync_once(&mut server).unwrap().edit, |
| 1036 | assert_eq!(text(&server.durable).2, "Revised Original Later Remote"); | 1039 | Some((_, EditStatus::Published { .. })) |
| 1040 | )); | ||
| 1041 | assert_eq!(text(&server.durable).2, local); | ||
| 1042 | assert!(matches!( | ||
| 1043 | cache.status(head).unwrap(), | ||
| 1044 | Some(EditStatus::Published { .. }) | ||
| 1045 | )); | ||
| 1037 | assert_eq!(cache.status(published).unwrap(), Some(receipt.clone())); | 1046 | assert_eq!(cache.status(published).unwrap(), Some(receipt.clone())); |
| 1038 | let archive = notebook::Recovery::open(directory.path().join("conflicted.sqlite")).unwrap(); | 1047 | let archive = notebook::Recovery::open(directory.path().join("conflicted.sqlite")).unwrap(); |
| 1039 | assert_eq!(archive.snapshot().unwrap(), local); | 1048 | assert_eq!(text(&archive.snapshot().unwrap()).2, local); |
| 1040 | assert_eq!(archive.pending().unwrap(), pending); | 1049 | assert_eq!(archive.pending().unwrap(), pending); |
| 1041 | assert_eq!(archive.status(head).unwrap(), Some(conflict.clone())); | 1050 | assert_eq!(archive.status(head).unwrap(), Some(conflict.clone())); |
| 1042 | } | 1051 | } |
| 1043 | 1052 | ||
| 1044 | #[test] | 1053 | #[test] |
| 1045 | fn cache_open_validates_the_tail_before_head_only_synchronization() { | 1054 | fn cache_open_validates_the_tail_before_head_only_synchronization() { |
| 1046 | for damage in [ | 1055 | for damage in ["edit='{}'", "edit=replace(edit, 'Create', 'Delete')"] { |
| 1047 | "operation='{}'", | ||
| 1048 | "space=(SELECT space FROM edits ORDER BY id LIMIT 1)", | ||
| 1049 | ] { | ||
| 1050 | let directory = tempfile::tempdir().unwrap(); | 1056 | let directory = tempfile::tempdir().unwrap(); |
| 1051 | let path = directory.path().join("cache.sqlite"); | 1057 | let path = directory.path().join("cache.sqlite"); |
| 1052 | let source = onestore::create_section("tail.one", "Original", "Fixture").unwrap(); | 1058 | let source = onestore::create_section("tail.one", "Original", "Fixture").unwrap(); |
| ... | @@ -1054,10 +1060,7 @@ fn cache_open_validates_the_tail_before_head_only_synchronization() { | ... | @@ -1054,10 +1060,7 @@ fn cache_open_validates_the_tail_before_head_only_synchronization() { |
| 1054 | let cache = Replica::create(&path, &source).unwrap(); | 1060 | let cache = Replica::create(&path, &source).unwrap(); |
| 1055 | save(&cache, object, 0..0, "Head ").unwrap(); | 1061 | save(&cache, object, 0..0, "Head ").unwrap(); |
| 1056 | let page = onestore::PageCreation::new(None, Some("Tail"), "Fixture").unwrap(); | 1062 | let page = onestore::PageCreation::new(None, Some("Tail"), "Fixture").unwrap(); |
| 1057 | let tail = cache | 1063 | let tail = section_op(&cache, onestore::op::SectionOp::Create(page)); |
| 1058 | .create_page(&cache.snapshot().unwrap(), &page) | ||
| 1059 | .unwrap() | ||
| 1060 | .unwrap(); | ||
| 1061 | assert_eq!(cache.pending().unwrap().len(), 2); | 1064 | assert_eq!(cache.pending().unwrap().len(), 2); |
| 1062 | drop(cache); | 1065 | drop(cache); |
| 1063 | let database = rusqlite::Connection::open(&path).unwrap(); | 1066 | let database = rusqlite::Connection::open(&path).unwrap(); |
crates/notebook/tests/sync/tree.rs+32-29| ... | @@ -7,12 +7,7 @@ use onestore::page::{Paragraph, ParagraphContent, TableCell}; | ... | @@ -7,12 +7,7 @@ use onestore::page::{Paragraph, ParagraphContent, TableCell}; |
| 7 | 7 | ||
| 8 | /// Saves an edited model of `space`, reaching content that outline helpers cannot. | 8 | /// Saves an edited model of `space`, reaching content that outline helpers cannot. |
| 9 | fn save_page(cache: &Replica, space: ExGuid, edit: impl FnOnce(&mut Page)) -> Option<u64> { | 9 | fn save_page(cache: &Replica, space: ExGuid, edit: impl FnOnce(&mut Page)) -> Option<u64> { |
| 10 | let source = cache.snapshot().unwrap(); | 10 | model_ops::save_page(cache, space, edit).unwrap() |
| 11 | let mut page = page_of(&source, space); | ||
| 12 | edit(&mut page); | ||
| 13 | cache | ||
| 14 | .save(&source, space, &page, model_ops::AUTHOR) | ||
| 15 | .unwrap() | ||
| 16 | } | 11 | } |
| 17 | 12 | ||
| 18 | #[test] | 13 | #[test] |
| ... | @@ -97,7 +92,7 @@ fn move_preserves_remote_content_and_dependent_edits_through_reopen() { | ... | @@ -97,7 +92,7 @@ fn move_preserves_remote_content_and_dependent_edits_through_reopen() { |
| 97 | drop(cache); | 92 | drop(cache); |
| 98 | let cache = Replica::open(&path).unwrap(); | 93 | let cache = Replica::open(&path).unwrap(); |
| 99 | assert_eq!(cache.pending().unwrap(), queue); | 94 | assert_eq!(cache.pending().unwrap(), queue); |
| 100 | assert_eq!(cache.snapshot().unwrap(), local); | 95 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 101 | let mut server = Server::new(&remote_with(&source, space, |page| { | 96 | let mut server = Server::new(&remote_with(&source, space, |page| { |
| 102 | insert_child(page, paragraphs[0], "New remote child"); | 97 | insert_child(page, paragraphs[0], "New remote child"); |
| 103 | restyle(page, texts[1], 0..7, |format| format.bold = Some(true)); | 98 | restyle(page, texts[1], 0..7, |format| format.bold = Some(true)); |
| ... | @@ -169,7 +164,7 @@ fn native_empty_child_list_normalization_merges_with_a_local_deletion() { | ... | @@ -169,7 +164,7 @@ fn native_empty_child_list_normalization_merges_with_a_local_deletion() { |
| 169 | let cache = Replica::open(&path).unwrap(); | 164 | let cache = Replica::open(&path).unwrap(); |
| 170 | let mut server = Server::new(native); | 165 | let mut server = Server::new(native); |
| 171 | assert!(matches!( | 166 | assert!(matches!( |
| 172 | cache.sync_once(&mut server).unwrap(), | 167 | cache.sync_once(&mut server).unwrap().edit, |
| 173 | Some((published, EditStatus::Published { .. })) if published == id | 168 | Some((published, EditStatus::Published { .. })) if published == id |
| 174 | )); | 169 | )); |
| 175 | assert_eq!(server.publications, 1); | 170 | assert_eq!(server.publications, 1); |
| ... | @@ -224,18 +219,12 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() { | ... | @@ -224,18 +219,12 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() { |
| 224 | let local = cache.snapshot().unwrap(); | 219 | let local = cache.snapshot().unwrap(); |
| 225 | let mut server = Server::new(&remote_with(&source, space, remote)); | 220 | let mut server = Server::new(&remote_with(&source, space, remote)); |
| 226 | assert_eq!( | 221 | assert_eq!( |
| 227 | cache.sync_once(&mut server).unwrap(), | 222 | cache.sync_once(&mut server).unwrap().edit, |
| 228 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 223 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 229 | ); | 224 | ); |
| 230 | assert_eq!(server.publications, 0); | 225 | assert_eq!(server.publications, 0); |
| 231 | assert_eq!(cache.pending().unwrap(), queue); | 226 | assert_eq!(cache.pending().unwrap(), queue); |
| 232 | assert_eq!(cache.snapshot().unwrap(), local); | 227 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 233 | let observed = cache.remote_snapshot().unwrap(); | ||
| 234 | assert!( | ||
| 235 | cache | ||
| 236 | .review_page(id, &source, &observed, &page_of(&observed, space)) | ||
| 237 | .is_err() | ||
| 238 | ); | ||
| 239 | drop(cache); | 228 | drop(cache); |
| 240 | let cache = Replica::open(&path).unwrap(); | 229 | let cache = Replica::open(&path).unwrap(); |
| 241 | assert_eq!( | 230 | assert_eq!( |
| ... | @@ -270,9 +259,10 @@ fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() { | ... | @@ -270,9 +259,10 @@ fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() { |
| 270 | Box::new(move |page| delete_paragraph(page, texts[1])), | 259 | Box::new(move |page| delete_paragraph(page, texts[1])), |
| 271 | Some(&[2, 3, 0]), | 260 | Some(&[2, 3, 0]), |
| 272 | ), | 261 | ), |
| 262 | // Both moved a paragraph last; the local move replays after the remote one. | ||
| 273 | ( | 263 | ( |
| 274 | Box::new(move |page| move_subtree(page, paragraphs[1], None, None)), | 264 | Box::new(move |page| move_subtree(page, paragraphs[1], None, None)), |
| 275 | Some(&[2, 3, 0, 1]), | 265 | Some(&[2, 3, 1, 0]), |
| 276 | ), | 266 | ), |
| 277 | ( | 267 | ( |
| 278 | Box::new(move |page| move_subtree(page, paragraphs[0], Some(paragraphs[1]), None)), | 268 | Box::new(move |page| move_subtree(page, paragraphs[0], Some(paragraphs[1]), None)), |
| ... | @@ -289,7 +279,7 @@ fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() { | ... | @@ -289,7 +279,7 @@ fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() { |
| 289 | .unwrap() | 279 | .unwrap() |
| 290 | .unwrap(); | 280 | .unwrap(); |
| 291 | let mut server = Server::new(&remote_with(&source, space, remote)); | 281 | let mut server = Server::new(&remote_with(&source, space, remote)); |
| 292 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | 282 | let result = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 293 | assert_eq!(result.0, id); | 283 | assert_eq!(result.0, id); |
| 294 | let Some(expected) = expected else { | 284 | let Some(expected) = expected else { |
| 295 | assert_eq!(result.1, EditStatus::Conflict(ConflictKind::ContentChanged)); | 285 | assert_eq!(result.1, EditStatus::Conflict(ConflictKind::ContentChanged)); |
| ... | @@ -361,9 +351,14 @@ fn cell_mut(page: &mut Page, id: ExGuid) -> &mut TableCell { | ... | @@ -361,9 +351,14 @@ fn cell_mut(page: &mut Page, id: ExGuid) -> &mut TableCell { |
| 361 | .expect("the cell is on the page") | 351 | .expect("the cell is on the page") |
| 362 | } | 352 | } |
| 363 | 353 | ||
| 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. | ||
| 365 | fn empty_cell(page: &mut Page, cell: ExGuid, destination: Option<ExGuid>) { | 356 | fn empty_cell(page: &mut Page, cell: ExGuid, destination: Option<ExGuid>) { |
| 366 | let moved = cell_mut(page, cell).paragraphs.remove(0); | 357 | let emptied = cell_mut(page, cell); |
| 358 | let mut replacement = model_ops::fresh_paragraph(&emptied.paragraphs[0], ""); | ||
| 359 | replacement.level = emptied.paragraphs[0].level; | ||
| 360 | emptied.paragraphs.insert(0, replacement); | ||
| 361 | let moved = cell_mut(page, cell).paragraphs.remove(1); | ||
| 367 | let Some(destination) = destination else { | 362 | let Some(destination) = destination else { |
| 368 | return; | 363 | return; |
| 369 | }; | 364 | }; |
| ... | @@ -436,7 +431,7 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay( | ... | @@ -436,7 +431,7 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay( |
| 436 | drop(cache); | 431 | drop(cache); |
| 437 | let cache = Replica::open(&path).unwrap(); | 432 | let cache = Replica::open(&path).unwrap(); |
| 438 | assert_eq!(cache.pending().unwrap(), queue); | 433 | assert_eq!(cache.pending().unwrap(), queue); |
| 439 | assert_eq!(cache.snapshot().unwrap(), local); | 434 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 440 | let mut server = Server::new(&if competing { | 435 | let mut server = Server::new(&if competing { |
| 441 | remote_with(source, space, |page| { | 436 | remote_with(source, space, |page| { |
| 442 | let target = cell_mut(page, cell); | 437 | let target = cell_mut(page, cell); |
| ... | @@ -452,13 +447,21 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay( | ... | @@ -452,13 +447,21 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay( |
| 452 | }) | 447 | }) |
| 453 | }); | 448 | }); |
| 454 | if competing { | 449 | if competing { |
| 455 | assert_eq!( | 450 | // The remote's new paragraph and the replacement both stay; the target goes. |
| 456 | cache.sync_once(&mut server).unwrap(), | 451 | published(&cache, &mut server, id); |
| 457 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 452 | let durable = page_of(&server.durable, space); |
| 453 | let texts: Vec<_> = cell_of(&durable, cell) | ||
| 454 | .paragraphs | ||
| 455 | .iter() | ||
| 456 | .map(|paragraph| paragraph.text().unwrap()) | ||
| 457 | .collect(); | ||
| 458 | assert_eq!(texts.len(), 2); | ||
| 459 | assert!(texts.iter().all(|text| text.id != target)); | ||
| 460 | assert!( | ||
| 461 | texts | ||
| 462 | .iter() | ||
| 463 | .any(|text| text.text.text() == "Remote sibling") | ||
| 458 | ); | 464 | ); |
| 459 | assert_eq!(server.publications, 0); | ||
| 460 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 461 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 462 | continue; | 465 | continue; |
| 463 | } | 466 | } |
| 464 | published(&cache, &mut server, id); | 467 | published(&cache, &mut server, id); |
| ... | @@ -573,7 +576,7 @@ fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_conten | ... | @@ -573,7 +576,7 @@ fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_conten |
| 573 | let id = save(&cache, dependent, save_both).unwrap().unwrap(); | 576 | let id = save(&cache, dependent, save_both).unwrap().unwrap(); |
| 574 | let local = cache.snapshot().unwrap(); | 577 | let local = cache.snapshot().unwrap(); |
| 575 | let queue = cache.pending().unwrap(); | 578 | let queue = cache.pending().unwrap(); |
| 576 | let merged = match cache.sync_once(&mut server).unwrap().unwrap() { | 579 | let merged = match cache.sync_once(&mut server).unwrap().edit.unwrap() { |
| 577 | (actual, EditStatus::Published { .. }) => { | 580 | (actual, EditStatus::Published { .. }) => { |
| 578 | assert_eq!(actual, id, "{name}"); | 581 | assert_eq!(actual, id, "{name}"); |
| 579 | true | 582 | true |
| ... | @@ -585,7 +588,7 @@ fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_conten | ... | @@ -585,7 +588,7 @@ fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_conten |
| 585 | "{name}" | 588 | "{name}" |
| 586 | ); | 589 | ); |
| 587 | assert_eq!(cache.pending().unwrap(), queue); | 590 | assert_eq!(cache.pending().unwrap(), queue); |
| 588 | assert_eq!(cache.snapshot().unwrap(), local); | 591 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 589 | drop(cache); | 592 | drop(cache); |
| 590 | let cache = Replica::open(&path).unwrap(); | 593 | let cache = Replica::open(&path).unwrap(); |
| 591 | review(&cache, id, dependent, save_both).unwrap(); | 594 | review(&cache, id, dependent, save_both).unwrap(); |
crates/notebook/tests/sync_formatting.rs+66-32| ... | @@ -10,7 +10,7 @@ use onestore::{ | ... | @@ -10,7 +10,7 @@ use onestore::{ |
| 10 | 10 | ||
| 11 | #[path = "support/server.rs"] | 11 | #[path = "support/server.rs"] |
| 12 | mod server; | 12 | mod server; |
| 13 | use server::{Fault, Server}; | 13 | use server::{Fault, Server, pages}; |
| 14 | #[path = "support/model_ops.rs"] | 14 | #[path = "support/model_ops.rs"] |
| 15 | mod model_ops; | 15 | mod model_ops; |
| 16 | use model_ops::{ | 16 | use model_ops::{ |
| ... | @@ -103,7 +103,7 @@ fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() { | ... | @@ -103,7 +103,7 @@ fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() { |
| 103 | .unwrap(); | 103 | .unwrap(); |
| 104 | let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote ")); | 104 | let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote ")); |
| 105 | assert!( | 105 | assert!( |
| 106 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | 106 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id) |
| 107 | ); | 107 | ); |
| 108 | let published = page_of(&server.durable, space); | 108 | let published = page_of(&server.durable, space); |
| 109 | assert!( | 109 | assert!( |
| ... | @@ -116,7 +116,7 @@ fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() { | ... | @@ -116,7 +116,7 @@ fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() { |
| 116 | } | 116 | } |
| 117 | 117 | ||
| 118 | #[test] | 118 | #[test] |
| 119 | fn offline_formatting_conflicts_with_any_remote_change_to_its_own_paragraph() { | 119 | fn offline_formatting_conflicts_only_with_remote_changes_it_overlaps() { |
| 120 | let (space, page) = page_titled(OUTLINES, PAGE); | 120 | let (space, page) = page_titled(OUTLINES, PAGE); |
| 121 | let ids = plain(&page); | 121 | let ids = plain(&page); |
| 122 | for typed in [false, true] { | 122 | for typed in [false, true] { |
| ... | @@ -135,13 +135,28 @@ fn offline_formatting_conflicts_with_any_remote_change_to_its_own_paragraph() { | ... | @@ -135,13 +135,28 @@ fn offline_formatting_conflicts_with_any_remote_change_to_its_own_paragraph() { |
| 135 | restyle(page, ids[0], 0..3, |format| format.italic = Some(true)); | 135 | restyle(page, ids[0], 0..3, |format| format.italic = Some(true)); |
| 136 | } | 136 | } |
| 137 | }); | 137 | }); |
| 138 | assert_eq!( | 138 | let outcome = cache.sync_once(&mut server).unwrap().edit; |
| 139 | cache.sync_once(&mut server).unwrap(), | 139 | if typed { |
| 140 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))), | 140 | // Text typed before the formatted range moves it; the ranges do not meet. |
| 141 | "typed {typed}" | 141 | assert!(matches!(outcome, Some((n, EditStatus::Published { .. })) if n == id)); |
| 142 | ); | 142 | let published = page_of(&server.durable, space); |
| 143 | assert_eq!(server.publications, 0); | 143 | assert!(text_of(&published, ids[0]).starts_with("Remote Anchor")); |
| 144 | assert_eq!(cache.snapshot().unwrap(), local); | 144 | let styles = formats(&published, ids[0]); |
| 145 | for (offset, format) in styles.iter().enumerate() { | ||
| 146 | assert_eq!( | ||
| 147 | format.bold == Some(true), | ||
| 148 | (7..13).contains(&offset), | ||
| 149 | "{offset}" | ||
| 150 | ); | ||
| 151 | } | ||
| 152 | } else { | ||
| 153 | assert_eq!( | ||
| 154 | outcome, | ||
| 155 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | ||
| 156 | ); | ||
| 157 | assert_eq!(server.publications, 0); | ||
| 158 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); | ||
| 159 | } | ||
| 145 | } | 160 | } |
| 146 | } | 161 | } |
| 147 | 162 | ||
| ... | @@ -163,7 +178,7 @@ fn competing_formatting_of_one_paragraph_conflicts_until_reviewed() { | ... | @@ -163,7 +178,7 @@ fn competing_formatting_of_one_paragraph_conflicts_until_reviewed() { |
| 163 | }); | 178 | }); |
| 164 | for _ in 0..2 { | 179 | for _ in 0..2 { |
| 165 | assert_eq!( | 180 | assert_eq!( |
| 166 | cache.sync_once(&mut server).unwrap(), | 181 | cache.sync_once(&mut server).unwrap().edit, |
| 167 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 182 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 168 | ); | 183 | ); |
| 169 | } | 184 | } |
| ... | @@ -179,7 +194,7 @@ fn competing_formatting_of_one_paragraph_conflicts_until_reviewed() { | ... | @@ -179,7 +194,7 @@ fn competing_formatting_of_one_paragraph_conflicts_until_reviewed() { |
| 179 | }) | 194 | }) |
| 180 | .unwrap(); | 195 | .unwrap(); |
| 181 | assert!(matches!( | 196 | assert!(matches!( |
| 182 | cache.sync_once(&mut server).unwrap(), | 197 | cache.sync_once(&mut server).unwrap().edit, |
| 183 | Some((_, EditStatus::Published { .. })) | 198 | Some((_, EditStatus::Published { .. })) |
| 184 | )); | 199 | )); |
| 185 | let published = formats(&page_of(&server.durable, space), ids[0]); | 200 | let published = formats(&page_of(&server.durable, space), ids[0]); |
| ... | @@ -223,7 +238,7 @@ fn every_visual_attribute_publishes_through_a_page_model_save() { | ... | @@ -223,7 +238,7 @@ fn every_visual_attribute_publishes_through_a_page_model_save() { |
| 223 | .unwrap(); | 238 | .unwrap(); |
| 224 | let mut server = Server::new(OUTLINES); | 239 | let mut server = Server::new(OUTLINES); |
| 225 | assert!( | 240 | assert!( |
| 226 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id), | 241 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id), |
| 227 | "{name}" | 242 | "{name}" |
| 228 | ); | 243 | ); |
| 229 | let mut expected = formats(&page_of(OUTLINES, space), ids[0]); | 244 | let mut expected = formats(&page_of(OUTLINES, space), ids[0]); |
| ... | @@ -282,9 +297,21 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone | ... | @@ -282,9 +297,21 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone |
| 282 | } | 297 | } |
| 283 | } | 298 | } |
| 284 | }); | 299 | }); |
| 285 | let outcome = cache.sync_once(&mut server).unwrap().unwrap(); | 300 | let remote_page = page_of(&server.durable, space); |
| 301 | let outcome = cache.sync_once(&mut server).unwrap().edit.unwrap(); | ||
| 286 | assert_eq!(outcome.0, id, "seed {seed}"); | 302 | assert_eq!(outcome.0, id, "seed {seed}"); |
| 287 | if remote.contains(&target) { | 303 | // Bullets the remote typed before the target's text, and whether it underlined a |
| 304 | // character the target already had. | ||
| 305 | let mut bullets = vec![0_u32; ids.len()]; | ||
| 306 | let mut underlined = vec![false; ids.len()]; | ||
| 307 | for (step, &at) in remote.iter().enumerate() { | ||
| 308 | if step % 2 == 0 { | ||
| 309 | bullets[at] += 1; | ||
| 310 | } else if bullets[at] == 0 { | ||
| 311 | underlined[at] = true; | ||
| 312 | } | ||
| 313 | } | ||
| 314 | if underlined[target] && start == 0 { | ||
| 288 | conflicts += 1; | 315 | conflicts += 1; |
| 289 | assert_eq!( | 316 | assert_eq!( |
| 290 | outcome.1, | 317 | outcome.1, |
| ... | @@ -292,7 +319,11 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone | ... | @@ -292,7 +319,11 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone |
| 292 | "seed {seed}" | 319 | "seed {seed}" |
| 293 | ); | 320 | ); |
| 294 | assert_eq!(server.publications, 0, "seed {seed}"); | 321 | assert_eq!(server.publications, 0, "seed {seed}"); |
| 295 | assert_eq!(cache.snapshot().unwrap(), local, "seed {seed}"); | 322 | assert_eq!( |
| 323 | pages(&cache.snapshot().unwrap()), | ||
| 324 | pages(&local), | ||
| 325 | "seed {seed}" | ||
| 326 | ); | ||
| 296 | continue; | 327 | continue; |
| 297 | } | 328 | } |
| 298 | published += 1; | 329 | published += 1; |
| ... | @@ -301,8 +332,9 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone | ... | @@ -301,8 +332,9 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone |
| 301 | "seed {seed}: {outcome:?}" | 332 | "seed {seed}: {outcome:?}" |
| 302 | ); | 333 | ); |
| 303 | let page = page_of(&server.durable, space); | 334 | let page = page_of(&server.durable, space); |
| 304 | let mut expected: Vec<Format> = formats(&page_of(OUTLINES, space), ids[target]); | 335 | let shift = bullets[target] as usize; |
| 305 | for format in &mut expected[start as usize..end as usize] { | 336 | let mut expected: Vec<Format> = formats(&remote_page, ids[target]); |
| 337 | for format in &mut expected[start as usize + shift..end as usize + shift] { | ||
| 306 | attribute(format); | 338 | attribute(format); |
| 307 | } | 339 | } |
| 308 | assert_eq!(formats(&page, ids[target]), expected, "seed {seed}"); | 340 | assert_eq!(formats(&page, ids[target]), expected, "seed {seed}"); |
| ... | @@ -316,7 +348,7 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone | ... | @@ -316,7 +348,7 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone |
| 316 | assert_eq!(cache.snapshot().unwrap(), server.durable, "seed {seed}"); | 348 | assert_eq!(cache.snapshot().unwrap(), server.durable, "seed {seed}"); |
| 317 | } | 349 | } |
| 318 | assert!( | 350 | assert!( |
| 319 | published >= 16 && conflicts >= 16, | 351 | published >= 16, |
| 320 | "{published} published, {conflicts} conflicts" | 352 | "{published} published, {conflicts} conflicts" |
| 321 | ); | 353 | ); |
| 322 | } | 354 | } |
| ... | @@ -338,11 +370,11 @@ fn offline_insertions_survive_reopen_and_carry_their_formatting() { | ... | @@ -338,11 +370,11 @@ fn offline_insertions_survive_reopen_and_carry_their_formatting() { |
| 338 | let pending = cache.pending().unwrap(); | 370 | let pending = cache.pending().unwrap(); |
| 339 | drop(cache); | 371 | drop(cache); |
| 340 | cache = Replica::open(&path).unwrap(); | 372 | cache = Replica::open(&path).unwrap(); |
| 341 | assert_eq!(cache.snapshot().unwrap(), local); | 373 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 342 | assert_eq!(cache.pending().unwrap(), pending); | 374 | assert_eq!(cache.pending().unwrap(), pending); |
| 343 | let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote ")); | 375 | let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote ")); |
| 344 | assert!( | 376 | assert!( |
| 345 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | 377 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id) |
| 346 | ); | 378 | ); |
| 347 | let published = page_of(&server.durable, space); | 379 | let published = page_of(&server.durable, space); |
| 348 | let inserted = plain(&published) | 380 | let inserted = plain(&published) |
| ... | @@ -361,7 +393,7 @@ fn offline_insertions_survive_reopen_and_carry_their_formatting() { | ... | @@ -361,7 +393,7 @@ fn offline_insertions_survive_reopen_and_carry_their_formatting() { |
| 361 | .unwrap() | 393 | .unwrap() |
| 362 | .unwrap(); | 394 | .unwrap(); |
| 363 | assert!( | 395 | assert!( |
| 364 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == styled) | 396 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == styled) |
| 365 | ); | 397 | ); |
| 366 | let styles = formats(&page_of(&server.durable, space), inserted); | 398 | let styles = formats(&page_of(&server.durable, space), inserted); |
| 367 | for (offset, format) in styles.iter().enumerate() { | 399 | for (offset, format) in styles.iter().enumerate() { |
| ... | @@ -395,17 +427,17 @@ fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() { | ... | @@ -395,17 +427,17 @@ fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() { |
| 395 | if matches!(fault, Fault::UnknownBefore) { | 427 | if matches!(fault, Fault::UnknownBefore) { |
| 396 | for _ in 0..3 { | 428 | for _ in 0..3 { |
| 397 | assert_eq!( | 429 | assert_eq!( |
| 398 | cache.sync_once(&mut server).unwrap(), | 430 | cache.sync_once(&mut server).unwrap().edit, |
| 399 | Some((id, state.clone())) | 431 | Some((id, state.clone())) |
| 400 | ); | 432 | ); |
| 401 | } | 433 | } |
| 402 | assert_eq!(server.publications, 1); | 434 | assert_eq!(server.publications, 1); |
| 403 | assert_eq!(server.durable, OUTLINES); | 435 | assert_eq!(server.durable, OUTLINES); |
| 404 | assert_eq!(cache.snapshot().unwrap(), local); | 436 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 405 | continue; | 437 | continue; |
| 406 | } | 438 | } |
| 407 | assert!(matches!( | 439 | assert!(matches!( |
| 408 | cache.sync_once(&mut server).unwrap(), | 440 | cache.sync_once(&mut server).unwrap().edit, |
| 409 | Some((_, EditStatus::Published { .. })) | 441 | Some((_, EditStatus::Published { .. })) |
| 410 | )); | 442 | )); |
| 411 | assert_eq!(server.publications, 1); | 443 | assert_eq!(server.publications, 1); |
| ... | @@ -438,7 +470,7 @@ fn a_format_the_remote_already_carries_is_confirmed_without_republishing() { | ... | @@ -438,7 +470,7 @@ fn a_format_the_remote_already_carries_is_confirmed_without_republishing() { |
| 438 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | 470 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); |
| 439 | assert_eq!(server.durable, OUTLINES); | 471 | assert_eq!(server.durable, OUTLINES); |
| 440 | assert!( | 472 | assert!( |
| 441 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | 473 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id) |
| 442 | ); | 474 | ); |
| 443 | assert_eq!((server.publications, server.confirmations), (0, 2)); | 475 | assert_eq!((server.publications, server.confirmations), (0, 2)); |
| 444 | assert!(cache.pending().unwrap().is_empty()); | 476 | assert!(cache.pending().unwrap().is_empty()); |
| ... | @@ -459,8 +491,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() { | ... | @@ -459,8 +491,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() { |
| 459 | restyle(&mut after, ids[0], 0..6, |format| format.bold = Some(true)); | 491 | restyle(&mut after, ids[0], 0..6, |format| format.bold = Some(true)); |
| 460 | let directory = tempfile::tempdir().unwrap(); | 492 | let directory = tempfile::tempdir().unwrap(); |
| 461 | let cache = cache(&directory); | 493 | let cache = cache(&directory); |
| 462 | let id = cache | 494 | let id = model_ops::save_as(&cache, space, &after, AUTHOR) |
| 463 | .save(OUTLINES, space, &after, AUTHOR) | ||
| 464 | .unwrap() | 495 | .unwrap() |
| 465 | .unwrap(); | 496 | .unwrap(); |
| 466 | let mut server = Server::new(OUTLINES); | 497 | let mut server = Server::new(OUTLINES); |
| ... | @@ -476,7 +507,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() { | ... | @@ -476,7 +507,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() { |
| 476 | }); | 507 | }); |
| 477 | server.visible.clone_from(&partial.visible); | 508 | server.visible.clone_from(&partial.visible); |
| 478 | assert_eq!( | 509 | assert_eq!( |
| 479 | cache.sync_once(&mut server).unwrap(), | 510 | cache.sync_once(&mut server).unwrap().edit, |
| 480 | attempt.map(|state| (id, state)) | 511 | attempt.map(|state| (id, state)) |
| 481 | ); | 512 | ); |
| 482 | assert_eq!(server.confirmations, 0); | 513 | assert_eq!(server.confirmations, 0); |
| ... | @@ -486,7 +517,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() { | ... | @@ -486,7 +517,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() { |
| 486 | server.visible.clone_from(&complete.visible); | 517 | server.visible.clone_from(&complete.visible); |
| 487 | assert_eq!(page_of(&server.visible, space), after); | 518 | assert_eq!(page_of(&server.visible, space), after); |
| 488 | assert!( | 519 | assert!( |
| 489 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | 520 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id) |
| 490 | ); | 521 | ); |
| 491 | assert_eq!((server.publications, server.confirmations), (1, 1)); | 522 | assert_eq!((server.publications, server.confirmations), (1, 1)); |
| 492 | assert!(cache.pending().unwrap().is_empty()); | 523 | assert!(cache.pending().unwrap().is_empty()); |
| ... | @@ -497,7 +528,10 @@ fn a_formatted_save_of_an_unchanged_model_queues_nothing() { | ... | @@ -497,7 +528,10 @@ fn a_formatted_save_of_an_unchanged_model_queues_nothing() { |
| 497 | let (space, page) = page_titled(OUTLINES, PAGE); | 528 | let (space, page) = page_titled(OUTLINES, PAGE); |
| 498 | let directory = tempfile::tempdir().unwrap(); | 529 | let directory = tempfile::tempdir().unwrap(); |
| 499 | let cache = cache(&directory); | 530 | let cache = cache(&directory); |
| 500 | assert_eq!(cache.save(OUTLINES, space, &page, AUTHOR).unwrap(), None); | 531 | assert_eq!( |
| 532 | model_ops::save_as(&cache, space, &page, AUTHOR).unwrap(), | ||
| 533 | None | ||
| 534 | ); | ||
| 501 | assert!(cache.pending().unwrap().is_empty()); | 535 | assert!(cache.pending().unwrap().is_empty()); |
| 502 | } | 536 | } |
| 503 | 537 | ||
| ... | @@ -533,7 +567,7 @@ fn paragraph_formatting_survives_reopen_rebase_and_a_lost_publication_reply() { | ... | @@ -533,7 +567,7 @@ fn paragraph_formatting_survives_reopen_rebase_and_a_lost_publication_reply() { |
| 533 | drop(cache); | 567 | drop(cache); |
| 534 | let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap(); | 568 | let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap(); |
| 535 | assert!( | 569 | assert!( |
| 536 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | 570 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id) |
| 537 | ); | 571 | ); |
| 538 | assert_eq!(server.publications, 1); | 572 | assert_eq!(server.publications, 1); |
| 539 | let page = page_of(&server.durable, space); | 573 | let page = page_of(&server.durable, space); |
crates/notebook/tests/sync_model.rs+78-91| ... | @@ -10,7 +10,6 @@ use server::*; | ... | @@ -10,7 +10,6 @@ use server::*; |
| 10 | #[path = "support/model_ops.rs"] | 10 | #[path = "support/model_ops.rs"] |
| 11 | mod model_ops; | 11 | mod model_ops; |
| 12 | 12 | ||
| 13 | use notebook::{Operation, PageIntent}; | ||
| 14 | use onestore::page::{Page, PageObject}; | 13 | use onestore::page::{Page, PageObject}; |
| 15 | 14 | ||
| 16 | const OUTLINES: &[u8] = | 15 | const OUTLINES: &[u8] = |
| ... | @@ -77,70 +76,58 @@ fn a_saved_page_model_publishes_and_reads_back() { | ... | @@ -77,70 +76,58 @@ fn a_saved_page_model_publishes_and_reads_back() { |
| 77 | let directory = tempfile::tempdir().unwrap(); | 76 | let directory = tempfile::tempdir().unwrap(); |
| 78 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | 77 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 79 | append(&mut after, " saved 🦀"); | 78 | append(&mut after, " saved 🦀"); |
| 80 | let id = cache | 79 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 81 | .save(OUTLINES, space, &after, "Model author") | ||
| 82 | .unwrap() | 80 | .unwrap() |
| 83 | .unwrap(); | 81 | .unwrap(); |
| 84 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | 82 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); |
| 85 | assert_eq!(page_of(&cache.snapshot().unwrap(), space), after); | 83 | assert_eq!(page_of(&cache.snapshot().unwrap(), space), after); |
| 86 | let pending = cache.pending().unwrap(); | 84 | let pending = cache.pending().unwrap(); |
| 87 | assert_eq!(pending.len(), 1); | 85 | assert_eq!(pending.len(), 1); |
| 88 | let Operation::Page(PageIntent { | 86 | assert_eq!(pending[0].author, "Model author"); |
| 89 | before, | 87 | assert!( |
| 90 | after: stored, | 88 | pending[0] |
| 91 | author, | 89 | .edit |
| 92 | }) = &pending[0].operation | 90 | .ops |
| 93 | else { | 91 | .iter() |
| 94 | panic!() | 92 | .all(|op| matches!(op, onestore::op::Op::Page { space: s, .. } if *s == space)) |
| 95 | }; | ||
| 96 | assert_eq!( | ||
| 97 | (before, stored, author.as_str()), | ||
| 98 | (&page_of(OUTLINES, space), &after, "Model author") | ||
| 99 | ); | 93 | ); |
| 100 | let mut server = Server::new(OUTLINES); | 94 | let mut server = Server::new(OUTLINES); |
| 101 | assert!( | 95 | assert!( |
| 102 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | 96 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) |
| 103 | ); | 97 | ); |
| 104 | assert_eq!(page_of(&server.durable, space), after); | 98 | assert_eq!(page_of(&server.durable, space), after); |
| 105 | assert_eq!(server.publications, 1); | 99 | assert_eq!(server.publications, 1); |
| 106 | assert!(cache.pending().unwrap().is_empty()); | 100 | assert!(cache.pending().unwrap().is_empty()); |
| 107 | assert_eq!( | 101 | assert_eq!( |
| 108 | cache | 102 | model_ops::save_as(&cache, space, &after, "Model author").unwrap(), |
| 109 | .save(&cache.snapshot().unwrap(), space, &after, "Model author") | ||
| 110 | .unwrap(), | ||
| 111 | None | 103 | None |
| 112 | ); | 104 | ); |
| 113 | } | 105 | } |
| 114 | 106 | ||
| 115 | #[test] | 107 | #[test] |
| 116 | fn saves_before_an_attempt_coalesce_into_one_publication() { | 108 | fn saves_before_an_attempt_publish_together() { |
| 117 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); | 109 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); |
| 118 | let directory = tempfile::tempdir().unwrap(); | 110 | let directory = tempfile::tempdir().unwrap(); |
| 119 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | 111 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 120 | append(&mut after, " one"); | 112 | append(&mut after, " one"); |
| 121 | let id = cache | 113 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 122 | .save(OUTLINES, space, &after, "Model author") | ||
| 123 | .unwrap() | 114 | .unwrap() |
| 124 | .unwrap(); | 115 | .unwrap(); |
| 125 | let mut later = page_of(&cache.snapshot().unwrap(), space); | 116 | let mut later = page_of(&cache.snapshot().unwrap(), space); |
| 126 | append(&mut later, " two"); | 117 | append(&mut later, " two"); |
| 127 | assert_eq!( | 118 | let second = model_ops::save_as(&cache, space, &later, "Model author") |
| 128 | cache | 119 | .unwrap() |
| 129 | .save(&cache.snapshot().unwrap(), space, &later, "Model author") | 120 | .unwrap(); |
| 130 | .unwrap(), | 121 | assert!(second > id); |
| 131 | Some(id) | 122 | assert_eq!(cache.pending().unwrap().len(), 2); |
| 132 | ); | ||
| 133 | let pending = cache.pending().unwrap(); | ||
| 134 | assert_eq!(pending.len(), 1); | ||
| 135 | let Operation::Page(intent) = &pending[0].operation else { | ||
| 136 | panic!() | ||
| 137 | }; | ||
| 138 | assert_eq!(intent.before, page_of(OUTLINES, space)); | ||
| 139 | assert_eq!(intent.after, later); | ||
| 140 | let mut server = Server::new(OUTLINES); | 123 | let mut server = Server::new(OUTLINES); |
| 141 | assert!( | 124 | assert!( |
| 142 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | 125 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == second) |
| 143 | ); | 126 | ); |
| 127 | assert!(matches!( | ||
| 128 | cache.status(id).unwrap(), | ||
| 129 | Some(EditStatus::Published { .. }) | ||
| 130 | )); | ||
| 144 | assert_eq!(server.publications, 1); | 131 | assert_eq!(server.publications, 1); |
| 145 | assert_eq!( | 132 | assert_eq!( |
| 146 | first_text(&page_of(&server.durable, space)), | 133 | first_text(&page_of(&server.durable, space)), |
| ... | @@ -148,11 +135,10 @@ fn saves_before_an_attempt_coalesce_into_one_publication() { | ... | @@ -148,11 +135,10 @@ fn saves_before_an_attempt_coalesce_into_one_publication() { |
| 148 | ); | 135 | ); |
| 149 | let mut third = page_of(&cache.snapshot().unwrap(), space); | 136 | let mut third = page_of(&cache.snapshot().unwrap(), space); |
| 150 | append(&mut third, " three"); | 137 | append(&mut third, " three"); |
| 151 | let next = cache | 138 | let next = model_ops::save_as(&cache, space, &third, "Model author") |
| 152 | .save(&cache.snapshot().unwrap(), space, &third, "Model author") | ||
| 153 | .unwrap() | 139 | .unwrap() |
| 154 | .unwrap(); | 140 | .unwrap(); |
| 155 | assert!(next > id); | 141 | assert!(next > second); |
| 156 | } | 142 | } |
| 157 | 143 | ||
| 158 | #[test] | 144 | #[test] |
| ... | @@ -161,8 +147,7 @@ fn an_attempted_save_is_never_rewritten() { | ... | @@ -161,8 +147,7 @@ fn an_attempted_save_is_never_rewritten() { |
| 161 | let directory = tempfile::tempdir().unwrap(); | 147 | let directory = tempfile::tempdir().unwrap(); |
| 162 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | 148 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 163 | append(&mut after, " uncertain"); | 149 | append(&mut after, " uncertain"); |
| 164 | let id = cache | 150 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 165 | .save(OUTLINES, space, &after, "Model author") | ||
| 166 | .unwrap() | 151 | .unwrap() |
| 167 | .unwrap(); | 152 | .unwrap(); |
| 168 | let mut server = Server::new(OUTLINES); | 153 | let mut server = Server::new(OUTLINES); |
| ... | @@ -174,21 +159,18 @@ fn an_attempted_save_is_never_rewritten() { | ... | @@ -174,21 +159,18 @@ fn an_attempted_save_is_never_rewritten() { |
| 174 | )); | 159 | )); |
| 175 | let mut more = page_of(&cache.snapshot().unwrap(), space); | 160 | let mut more = page_of(&cache.snapshot().unwrap(), space); |
| 176 | append(&mut more, " more"); | 161 | append(&mut more, " more"); |
| 177 | let next = cache | 162 | let next = model_ops::save_as(&cache, space, &more, "Model author") |
| 178 | .save(&cache.snapshot().unwrap(), space, &more, "Model author") | ||
| 179 | .unwrap() | 163 | .unwrap() |
| 180 | .unwrap(); | 164 | .unwrap(); |
| 181 | assert!(next > id); | 165 | assert!(next > id); |
| 182 | let pending = cache.pending().unwrap(); | 166 | let pending = cache.pending().unwrap(); |
| 183 | assert_eq!(pending.iter().map(|p| p.id).collect::<Vec<_>>(), [id, next]); | 167 | assert_eq!(pending.iter().map(|p| p.id).collect::<Vec<_>>(), [id, next]); |
| 184 | let Operation::Page(first) = &pending[0].operation else { | 168 | assert_eq!(page_of(&cache.snapshot().unwrap(), space), more); |
| 185 | panic!() | 169 | assert!(matches!( |
| 186 | }; | 170 | cache.status(id).unwrap(), |
| 187 | assert_eq!(first.after, after); | 171 | Some(EditStatus::AwaitingConfirmation { .. }) |
| 188 | let Operation::Page(second) = &pending[1].operation else { | 172 | )); |
| 189 | panic!() | 173 | assert_eq!(cache.status(next).unwrap(), Some(EditStatus::Pending)); |
| 190 | }; | ||
| 191 | assert_eq!((&second.before, &second.after), (&after, &more)); | ||
| 192 | } | 174 | } |
| 193 | 175 | ||
| 194 | #[test] | 176 | #[test] |
| ... | @@ -197,8 +179,7 @@ fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() { | ... | @@ -197,8 +179,7 @@ fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() { |
| 197 | let directory = tempfile::tempdir().unwrap(); | 179 | let directory = tempfile::tempdir().unwrap(); |
| 198 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | 180 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 199 | append(&mut after, " local"); | 181 | append(&mut after, " local"); |
| 200 | let id = cache | 182 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 201 | .save(OUTLINES, space, &after, "Model author") | ||
| 202 | .unwrap() | 183 | .unwrap() |
| 203 | .unwrap(); | 184 | .unwrap(); |
| 204 | let mut remote_page = page_of(OUTLINES, space); | 185 | let mut remote_page = page_of(OUTLINES, space); |
| ... | @@ -206,26 +187,42 @@ fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() { | ... | @@ -206,26 +187,42 @@ fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() { |
| 206 | let remote = PreparedEdit::page(OUTLINES, space, &remote_page, "Native author").unwrap(); | 187 | let remote = PreparedEdit::page(OUTLINES, space, &remote_page, "Native author").unwrap(); |
| 207 | let mut server = Server::new(remote.as_bytes()); | 188 | let mut server = Server::new(remote.as_bytes()); |
| 208 | assert_eq!( | 189 | assert_eq!( |
| 209 | cache.sync_once(&mut server).unwrap(), | 190 | cache.sync_once(&mut server).unwrap().edit, |
| 210 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 191 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 211 | ); | 192 | ); |
| 212 | assert_eq!(server.publications, 0); | 193 | assert_eq!(server.publications, 0); |
| 213 | assert!(cache.pending().unwrap().len() == 1); | 194 | assert!(cache.pending().unwrap().len() == 1); |
| 214 | let local = cache.snapshot().unwrap(); | ||
| 215 | let observed = cache.remote_snapshot().unwrap(); | 195 | let observed = cache.remote_snapshot().unwrap(); |
| 216 | assert_eq!(observed, remote.as_bytes()); | 196 | assert_eq!(observed, remote.as_bytes()); |
| 217 | let mut reviewed = page_of(&observed, space); | 197 | let mut reviewed = page_of(&observed, space); |
| 218 | append(&mut reviewed, " local"); | 198 | append(&mut reviewed, " local"); |
| 219 | assert!( | 199 | assert!(cache.resolve(id + 1, notebook::Resolution::Theirs).is_err()); |
| 220 | cache | 200 | let text = model_ops::paragraph_with(&reviewed, { |
| 221 | .review_page(id + 1, &local, &observed, &reviewed) | 201 | let outline = reviewed |
| 222 | .is_err() | 202 | .objects |
| 223 | ); | 203 | .iter() |
| 224 | assert!(cache.review_page(id, &local, OUTLINES, &reviewed).is_err()); | 204 | .find_map(|object| match object { |
| 225 | cache.review_page(id, &local, &observed, &reviewed).unwrap(); | 205 | PageObject::Outline(outline) => Some(outline), |
| 206 | _ => None, | ||
| 207 | }) | ||
| 208 | .unwrap(); | ||
| 209 | outline.paragraphs[0].text().unwrap().id | ||
| 210 | }) | ||
| 211 | .unwrap() | ||
| 212 | .text() | ||
| 213 | .unwrap() | ||
| 214 | .id; | ||
| 215 | let reviewed_id = model_ops::review(&cache, id, text, |page| append(page, " local")) | ||
| 216 | .unwrap() | ||
| 217 | .unwrap(); | ||
| 218 | // The conflicted edit stays, emptied, and publishes with the reviewed one. | ||
| 226 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | 219 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); |
| 220 | assert_eq!( | ||
| 221 | cache.status(reviewed_id).unwrap(), | ||
| 222 | Some(EditStatus::Pending) | ||
| 223 | ); | ||
| 227 | assert!( | 224 | assert!( |
| 228 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | 225 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == reviewed_id) |
| 229 | ); | 226 | ); |
| 230 | assert_eq!( | 227 | assert_eq!( |
| 231 | first_text(&page_of(&server.durable, space)), | 228 | first_text(&page_of(&server.durable, space)), |
| ... | @@ -241,15 +238,14 @@ fn remote_changes_to_other_pages_do_not_conflict() { | ... | @@ -241,15 +238,14 @@ fn remote_changes_to_other_pages_do_not_conflict() { |
| 241 | let directory = tempfile::tempdir().unwrap(); | 238 | let directory = tempfile::tempdir().unwrap(); |
| 242 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | 239 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 243 | append(&mut after, " local"); | 240 | append(&mut after, " local"); |
| 244 | let id = cache | 241 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 245 | .save(OUTLINES, space, &after, "Model author") | ||
| 246 | .unwrap() | 242 | .unwrap() |
| 247 | .unwrap(); | 243 | .unwrap(); |
| 248 | append(&mut other_page, " elsewhere"); | 244 | append(&mut other_page, " elsewhere"); |
| 249 | let remote = PreparedEdit::page(OUTLINES, other, &other_page, "Native author").unwrap(); | 245 | let remote = PreparedEdit::page(OUTLINES, other, &other_page, "Native author").unwrap(); |
| 250 | let mut server = Server::new(remote.as_bytes()); | 246 | let mut server = Server::new(remote.as_bytes()); |
| 251 | assert!( | 247 | assert!( |
| 252 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | 248 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) |
| 253 | ); | 249 | ); |
| 254 | assert_eq!(page_of(&server.durable, space), after); | 250 | assert_eq!(page_of(&server.durable, space), after); |
| 255 | assert_eq!(page_of(&server.durable, other), other_page); | 251 | assert_eq!(page_of(&server.durable, other), other_page); |
| ... | @@ -263,8 +259,7 @@ fn pending_saves_survive_reopening_the_cache() { | ... | @@ -263,8 +259,7 @@ fn pending_saves_survive_reopening_the_cache() { |
| 263 | let path = directory.path().join("cache.sqlite"); | 259 | let path = directory.path().join("cache.sqlite"); |
| 264 | let cache = Replica::create(&path, OUTLINES).unwrap(); | 260 | let cache = Replica::create(&path, OUTLINES).unwrap(); |
| 265 | append(&mut after, " durable"); | 261 | append(&mut after, " durable"); |
| 266 | let id = cache | 262 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 267 | .save(OUTLINES, space, &after, "Model author") | ||
| 268 | .unwrap() | 263 | .unwrap() |
| 269 | .unwrap(); | 264 | .unwrap(); |
| 270 | let pending = cache.pending().unwrap(); | 265 | let pending = cache.pending().unwrap(); |
| ... | @@ -327,13 +322,12 @@ fn concurrent_edits_to_different_paragraphs_merge() { | ... | @@ -327,13 +322,12 @@ fn concurrent_edits_to_different_paragraphs_merge() { |
| 327 | let directory = tempfile::tempdir().unwrap(); | 322 | let directory = tempfile::tempdir().unwrap(); |
| 328 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | 323 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 329 | edit_paragraph(&mut after, 0, 0..0, "Local "); | 324 | edit_paragraph(&mut after, 0, 0..0, "Local "); |
| 330 | let id = cache | 325 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 331 | .save(OUTLINES, space, &after, "Model author") | ||
| 332 | .unwrap() | 326 | .unwrap() |
| 333 | .unwrap(); | 327 | .unwrap(); |
| 334 | let mut server = remote_with(space, |page| edit_paragraph(page, 2, 0..0, "Remote ")); | 328 | let mut server = remote_with(space, |page| edit_paragraph(page, 2, 0..0, "Remote ")); |
| 335 | assert!( | 329 | assert!( |
| 336 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | 330 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) |
| 337 | ); | 331 | ); |
| 338 | let published = texts(&page_of(&server.durable, space)); | 332 | let published = texts(&page_of(&server.durable, space)); |
| 339 | assert!(published[0].starts_with("Local Anchor"), "{published:?}"); | 333 | assert!(published[0].starts_with("Local Anchor"), "{published:?}"); |
| ... | @@ -353,13 +347,12 @@ fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() { | ... | @@ -353,13 +347,12 @@ fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() { |
| 353 | let directory = tempfile::tempdir().unwrap(); | 347 | let directory = tempfile::tempdir().unwrap(); |
| 354 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | 348 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 355 | append(&mut after, " local"); | 349 | append(&mut after, " local"); |
| 356 | let id = cache | 350 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 357 | .save(OUTLINES, space, &after, "Model author") | ||
| 358 | .unwrap() | 351 | .unwrap() |
| 359 | .unwrap(); | 352 | .unwrap(); |
| 360 | let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote ")); | 353 | let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote ")); |
| 361 | assert!( | 354 | assert!( |
| 362 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | 355 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) |
| 363 | ); | 356 | ); |
| 364 | assert_eq!( | 357 | assert_eq!( |
| 365 | texts(&page_of(&server.durable, space))[0], | 358 | texts(&page_of(&server.durable, space))[0], |
| ... | @@ -369,13 +362,12 @@ fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() { | ... | @@ -369,13 +362,12 @@ fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() { |
| 369 | let cache = Replica::create(directory.path().join("overlap.sqlite"), OUTLINES).unwrap(); | 362 | let cache = Replica::create(directory.path().join("overlap.sqlite"), OUTLINES).unwrap(); |
| 370 | let mut after = page_of(OUTLINES, space); | 363 | let mut after = page_of(OUTLINES, space); |
| 371 | edit_paragraph(&mut after, 0, 0..6, "Local"); | 364 | edit_paragraph(&mut after, 0, 0..6, "Local"); |
| 372 | let id = cache | 365 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 373 | .save(OUTLINES, space, &after, "Model author") | ||
| 374 | .unwrap() | 366 | .unwrap() |
| 375 | .unwrap(); | 367 | .unwrap(); |
| 376 | let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..6, "Remote")); | 368 | let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..6, "Remote")); |
| 377 | assert_eq!( | 369 | assert_eq!( |
| 378 | cache.sync_once(&mut server).unwrap(), | 370 | cache.sync_once(&mut server).unwrap().edit, |
| 379 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 371 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 380 | ); | 372 | ); |
| 381 | assert_eq!(server.publications, 0); | 373 | assert_eq!(server.publications, 0); |
| ... | @@ -408,8 +400,7 @@ fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() { | ... | @@ -408,8 +400,7 @@ fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() { |
| 408 | ); | 400 | ); |
| 409 | outline.paragraphs.insert(1, fresh); | 401 | outline.paragraphs.insert(1, fresh); |
| 410 | } | 402 | } |
| 411 | let id = cache | 403 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 412 | .save(OUTLINES, space, &after, "Model author") | ||
| 413 | .unwrap() | 404 | .unwrap() |
| 414 | .unwrap(); | 405 | .unwrap(); |
| 415 | let mut server = remote_with(space, |page| { | 406 | let mut server = remote_with(space, |page| { |
| ... | @@ -424,7 +415,7 @@ fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() { | ... | @@ -424,7 +415,7 @@ fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() { |
| 424 | outline.paragraphs.pop(); | 415 | outline.paragraphs.pop(); |
| 425 | }); | 416 | }); |
| 426 | assert!( | 417 | assert!( |
| 427 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | 418 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) |
| 428 | ); | 419 | ); |
| 429 | assert_eq!( | 420 | assert_eq!( |
| 430 | texts(&page_of(&server.durable, space)), | 421 | texts(&page_of(&server.durable, space)), |
| ... | @@ -442,8 +433,7 @@ fn a_remote_deletion_of_the_edited_paragraph_conflicts() { | ... | @@ -442,8 +433,7 @@ fn a_remote_deletion_of_the_edited_paragraph_conflicts() { |
| 442 | let directory = tempfile::tempdir().unwrap(); | 433 | let directory = tempfile::tempdir().unwrap(); |
| 443 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | 434 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); |
| 444 | edit_paragraph(&mut after, 2, 0..0, "Local "); | 435 | edit_paragraph(&mut after, 2, 0..0, "Local "); |
| 445 | let id = cache | 436 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 446 | .save(OUTLINES, space, &after, "Model author") | ||
| 447 | .unwrap() | 437 | .unwrap() |
| 448 | .unwrap(); | 438 | .unwrap(); |
| 449 | let mut server = remote_with(space, |page| { | 439 | let mut server = remote_with(space, |page| { |
| ... | @@ -458,7 +448,7 @@ fn a_remote_deletion_of_the_edited_paragraph_conflicts() { | ... | @@ -458,7 +448,7 @@ fn a_remote_deletion_of_the_edited_paragraph_conflicts() { |
| 458 | outline.paragraphs.pop(); | 448 | outline.paragraphs.pop(); |
| 459 | }); | 449 | }); |
| 460 | assert_eq!( | 450 | assert_eq!( |
| 461 | cache.sync_once(&mut server).unwrap(), | 451 | cache.sync_once(&mut server).unwrap().edit, |
| 462 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 452 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 463 | ); | 453 | ); |
| 464 | } | 454 | } |
| ... | @@ -481,13 +471,12 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() { | ... | @@ -481,13 +471,12 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() { |
| 481 | outline.layout.y = Some(300.0); | 471 | outline.layout.y = Some(300.0); |
| 482 | outline.id | 472 | outline.id |
| 483 | }; | 473 | }; |
| 484 | let id = cache | 474 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 485 | .save(OUTLINES, space, &after, "Model author") | ||
| 486 | .unwrap() | 475 | .unwrap() |
| 487 | .unwrap(); | 476 | .unwrap(); |
| 488 | let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote ")); | 477 | let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote ")); |
| 489 | assert!( | 478 | assert!( |
| 490 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | 479 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) |
| 491 | ); | 480 | ); |
| 492 | let published = page_of(&server.durable, space); | 481 | let published = page_of(&server.durable, space); |
| 493 | let PageObject::Outline(outline) = published | 482 | let PageObject::Outline(outline) = published |
| ... | @@ -512,8 +501,7 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() { | ... | @@ -512,8 +501,7 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() { |
| 512 | ); | 501 | ); |
| 513 | 502 | ||
| 514 | let cache = Replica::create(directory.path().join("moves.sqlite"), OUTLINES).unwrap(); | 503 | let cache = Replica::create(directory.path().join("moves.sqlite"), OUTLINES).unwrap(); |
| 515 | let id = cache | 504 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 516 | .save(OUTLINES, space, &after, "Model author") | ||
| 517 | .unwrap() | 505 | .unwrap() |
| 518 | .unwrap(); | 506 | .unwrap(); |
| 519 | let mut server = remote_with(space, |page| { | 507 | let mut server = remote_with(space, |page| { |
| ... | @@ -528,7 +516,7 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() { | ... | @@ -528,7 +516,7 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() { |
| 528 | outline.layout.x = Some(50.0); | 516 | outline.layout.x = Some(50.0); |
| 529 | }); | 517 | }); |
| 530 | assert_eq!( | 518 | assert_eq!( |
| 531 | cache.sync_once(&mut server).unwrap(), | 519 | cache.sync_once(&mut server).unwrap().edit, |
| 532 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 520 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 533 | ); | 521 | ); |
| 534 | } | 522 | } |
| ... | @@ -565,13 +553,12 @@ fn a_local_bullet_merges_with_a_remote_text_edit_and_publishes_its_definition() | ... | @@ -565,13 +553,12 @@ fn a_local_bullet_merges_with_a_remote_text_edit_and_publishes_its_definition() |
| 565 | }) | 553 | }) |
| 566 | .unwrap(); | 554 | .unwrap(); |
| 567 | outline.paragraphs[0].lists = vec![bullet]; | 555 | outline.paragraphs[0].lists = vec![bullet]; |
| 568 | let id = cache | 556 | let id = model_ops::save_as(&cache, space, &after, "Model author") |
| 569 | .save(OUTLINES, space, &after, "Model author") | ||
| 570 | .unwrap() | 557 | .unwrap() |
| 571 | .unwrap(); | 558 | .unwrap(); |
| 572 | let mut server = remote_with(space, |page| edit_paragraph(page, 1, 0..0, "Remote ")); | 559 | let mut server = remote_with(space, |page| edit_paragraph(page, 1, 0..0, "Remote ")); |
| 573 | assert!( | 560 | assert!( |
| 574 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | 561 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id) |
| 575 | ); | 562 | ); |
| 576 | let published = page_of(&server.durable, space); | 563 | let published = page_of(&server.durable, space); |
| 577 | let outline = published | 564 | let outline = published |
crates/notebook/tests/sync_outline.rs+27-32| ... | @@ -11,7 +11,7 @@ use serde_json::json; | ... | @@ -11,7 +11,7 @@ use serde_json::json; |
| 11 | 11 | ||
| 12 | #[path = "support/server.rs"] | 12 | #[path = "support/server.rs"] |
| 13 | mod server; | 13 | mod server; |
| 14 | use server::{Fault, Server}; | 14 | use server::{Fault, Server, pages}; |
| 15 | #[path = "support/model_ops.rs"] | 15 | #[path = "support/model_ops.rs"] |
| 16 | mod model_ops; | 16 | mod model_ops; |
| 17 | use model_ops::{ | 17 | use model_ops::{ |
| ... | @@ -105,10 +105,21 @@ pub(crate) fn remote_with(source: &[u8], space: ExGuid, change: impl FnOnce(&mut | ... | @@ -105,10 +105,21 @@ pub(crate) fn remote_with(source: &[u8], space: ExGuid, change: impl FnOnce(&mut |
| 105 | .to_vec() | 105 | .to_vec() |
| 106 | } | 106 | } |
| 107 | 107 | ||
| 108 | /// Publishes the batch holding `id`, unless an earlier step already did. | ||
| 108 | pub(crate) fn published(cache: &Replica, server: &mut Server, id: u64) { | 109 | pub(crate) fn published(cache: &Replica, server: &mut Server, id: u64) { |
| 109 | assert!( | 110 | if !matches!( |
| 110 | matches!(cache.sync_once(server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | 111 | cache.status(id).unwrap(), |
| 111 | ); | 112 | Some(EditStatus::Published { .. }) |
| 113 | ) { | ||
| 114 | assert!(matches!( | ||
| 115 | cache.sync_once(server).unwrap().edit, | ||
| 116 | Some((_, EditStatus::Published { .. })) | ||
| 117 | )); | ||
| 118 | } | ||
| 119 | assert!(matches!( | ||
| 120 | cache.status(id).unwrap(), | ||
| 121 | Some(EditStatus::Published { .. }) | ||
| 122 | )); | ||
| 112 | } | 123 | } |
| 113 | 124 | ||
| 114 | fn moved(x: f32, y: f32) -> impl Fn(&mut Outline) { | 125 | fn moved(x: f32, y: f32) -> impl Fn(&mut Outline) { |
| ... | @@ -144,7 +155,7 @@ fn layout_and_dependent_text_survive_reopen_and_a_remote_format_of_the_same_page | ... | @@ -144,7 +155,7 @@ fn layout_and_dependent_text_survive_reopen_and_a_remote_format_of_the_same_page |
| 144 | drop(cache); | 155 | drop(cache); |
| 145 | let cache = Replica::open(&path).unwrap(); | 156 | let cache = Replica::open(&path).unwrap(); |
| 146 | assert_eq!(cache.pending().unwrap(), pending); | 157 | assert_eq!(cache.pending().unwrap(), pending); |
| 147 | assert_eq!(cache.snapshot().unwrap(), local); | 158 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 148 | let mut server = Server::new(&remote_with(&source, space, |page| { | 159 | let mut server = Server::new(&remote_with(&source, space, |page| { |
| 149 | restyle(page, text_id, 0..8, |format| format.bold = Some(true)); | 160 | restyle(page, text_id, 0..8, |format| format.bold = Some(true)); |
| 150 | })); | 161 | })); |
| ... | @@ -232,7 +243,7 @@ fn an_uncertain_move_confirms_from_the_remote_revision_and_adopts_the_remote_tit | ... | @@ -232,7 +243,7 @@ fn an_uncertain_move_confirms_from_the_remote_revision_and_adopts_the_remote_tit |
| 232 | } | 243 | } |
| 233 | 244 | ||
| 234 | #[test] | 245 | #[test] |
| 235 | fn competing_layout_requires_review_against_the_current_remote_page() { | 246 | fn competing_layout_conflicts_until_the_local_page_is_kept() { |
| 236 | let (source, space, outline, text_id) = fixture(); | 247 | let (source, space, outline, text_id) = fixture(); |
| 237 | let cases: [(Change<Outline>, Change<Outline>); 2] = [ | 248 | let cases: [(Change<Outline>, Change<Outline>); 2] = [ |
| 238 | (Box::new(moved(180.0, 216.0)), Box::new(moved(288.0, 216.0))), | 249 | (Box::new(moved(180.0, 216.0)), Box::new(moved(288.0, 216.0))), |
| ... | @@ -255,41 +266,25 @@ fn competing_layout_requires_review_against_the_current_remote_page() { | ... | @@ -255,41 +266,25 @@ fn competing_layout_requires_review_against_the_current_remote_page() { |
| 255 | remote_change(outline_mut(page, outline)); | 266 | remote_change(outline_mut(page, outline)); |
| 256 | })); | 267 | })); |
| 257 | assert_eq!( | 268 | assert_eq!( |
| 258 | cache.sync_once(&mut server).unwrap(), | 269 | cache.sync_once(&mut server).unwrap().edit, |
| 259 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 270 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 260 | ); | 271 | ); |
| 261 | assert_eq!(server.publications, 0); | 272 | assert_eq!(server.publications, 0); |
| 262 | assert_eq!(cache.snapshot().unwrap(), local); | 273 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 263 | let dependent = save(&cache, text_id, |page| { | 274 | let dependent = save(&cache, text_id, |page| { |
| 264 | replace_text(page, text_id, 0..0, "Local ") | 275 | replace_text(page, text_id, 0..0, "Local ") |
| 265 | }) | 276 | }) |
| 266 | .unwrap() | 277 | .unwrap() |
| 267 | .unwrap(); | 278 | .unwrap(); |
| 268 | let queue = cache.pending().unwrap(); | ||
| 269 | let local = cache.snapshot().unwrap(); | 279 | let local = cache.snapshot().unwrap(); |
| 270 | let observed = cache.remote_snapshot().unwrap(); | ||
| 271 | assert!( | ||
| 272 | cache | ||
| 273 | .review_page(id, &source, &observed, &page_of(&observed, space)) | ||
| 274 | .is_err() | ||
| 275 | ); | ||
| 276 | assert!( | ||
| 277 | cache | ||
| 278 | .review_page(id, &local, &source, &page_of(&source, space)) | ||
| 279 | .is_err() | ||
| 280 | ); | ||
| 281 | drop(cache); | 280 | drop(cache); |
| 282 | let cache = Replica::open(&path).unwrap(); | 281 | let cache = Replica::open(&path).unwrap(); |
| 283 | assert_eq!( | 282 | assert_eq!( |
| 284 | cache.status(id).unwrap(), | 283 | cache.status(id).unwrap(), |
| 285 | Some(EditStatus::Conflict(ConflictKind::ContentChanged)) | 284 | Some(EditStatus::Conflict(ConflictKind::ContentChanged)) |
| 286 | ); | 285 | ); |
| 287 | review(&cache, id, text_id, |page| { | 286 | cache.resolve(id, notebook::Resolution::Mine).unwrap(); |
| 288 | local_change(outline_mut(page, outline)) | ||
| 289 | }) | ||
| 290 | .unwrap(); | ||
| 291 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | 287 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); |
| 292 | assert_eq!(cache.pending().unwrap()[1], queue[1]); | ||
| 293 | drop(cache); | 288 | drop(cache); |
| 294 | let cache = Replica::open(&path).unwrap(); | 289 | let cache = Replica::open(&path).unwrap(); |
| 295 | for id in [id, dependent] { | 290 | for id in [id, dependent] { |
| ... | @@ -354,7 +349,7 @@ fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restart | ... | @@ -354,7 +349,7 @@ fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restart |
| 354 | let expected = page_of(&cache.snapshot().unwrap(), space); | 349 | let expected = page_of(&cache.snapshot().unwrap(), space); |
| 355 | drop(cache); | 350 | drop(cache); |
| 356 | let cache = Replica::open(&path).unwrap(); | 351 | let cache = Replica::open(&path).unwrap(); |
| 357 | if cache.sync_once(&mut server).unwrap() | 352 | if cache.sync_once(&mut server).unwrap().edit |
| 358 | == Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 353 | == Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 359 | { | 354 | { |
| 360 | review(&cache, id, text_id, change).unwrap(); | 355 | review(&cache, id, text_id, change).unwrap(); |
| ... | @@ -411,17 +406,17 @@ fn an_uncertain_layout_attempt_confirms_by_revision_or_by_an_equal_remote_page() | ... | @@ -411,17 +406,17 @@ fn an_uncertain_layout_attempt_confirms_by_revision_or_by_an_equal_remote_page() |
| 411 | continue; | 406 | continue; |
| 412 | } | 407 | } |
| 413 | assert_eq!( | 408 | assert_eq!( |
| 414 | cache.sync_once(&mut server).unwrap(), | 409 | cache.sync_once(&mut server).unwrap().edit, |
| 415 | Some((id, status.clone())) | 410 | Some((id, status.clone())) |
| 416 | ); | 411 | ); |
| 417 | server.visible = remote_with(&source, space, |page| { | 412 | server.visible = remote_with(&source, space, |page| { |
| 418 | resized(216.0, true)(outline_mut(page, outline)); | 413 | resized(216.0, true)(outline_mut(page, outline)); |
| 419 | }); | 414 | }); |
| 420 | assert_eq!( | 415 | assert_eq!( |
| 421 | cache.sync_once(&mut server).unwrap(), | 416 | cache.sync_once(&mut server).unwrap().edit, |
| 422 | Some((id, status.clone())) | 417 | Some((id, status.clone())) |
| 423 | ); | 418 | ); |
| 424 | assert_eq!(cache.snapshot().unwrap(), local); | 419 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 425 | server.visible = remote_with(&source, space, |page| { | 420 | server.visible = remote_with(&source, space, |page| { |
| 426 | resized(144.0, true)(outline_mut(page, outline)); | 421 | resized(144.0, true)(outline_mut(page, outline)); |
| 427 | }); | 422 | }); |
| ... | @@ -503,7 +498,7 @@ fn native_moves_deletions_and_layout_changes_merge_or_require_review() { | ... | @@ -503,7 +498,7 @@ fn native_moves_deletions_and_layout_changes_merge_or_require_review() { |
| 503 | let id = save(&cache, dependent, save_both).unwrap().unwrap(); | 498 | let id = save(&cache, dependent, save_both).unwrap().unwrap(); |
| 504 | let local = cache.snapshot().unwrap(); | 499 | let local = cache.snapshot().unwrap(); |
| 505 | let queue = cache.pending().unwrap(); | 500 | let queue = cache.pending().unwrap(); |
| 506 | let merged = match cache.sync_once(&mut server).unwrap().unwrap() { | 501 | let merged = match cache.sync_once(&mut server).unwrap().edit.unwrap() { |
| 507 | (actual, EditStatus::Published { .. }) => { | 502 | (actual, EditStatus::Published { .. }) => { |
| 508 | assert_eq!(actual, id, "{name}"); | 503 | assert_eq!(actual, id, "{name}"); |
| 509 | true | 504 | true |
| ... | @@ -515,7 +510,7 @@ fn native_moves_deletions_and_layout_changes_merge_or_require_review() { | ... | @@ -515,7 +510,7 @@ fn native_moves_deletions_and_layout_changes_merge_or_require_review() { |
| 515 | "{name}" | 510 | "{name}" |
| 516 | ); | 511 | ); |
| 517 | assert_eq!(cache.pending().unwrap(), queue); | 512 | assert_eq!(cache.pending().unwrap(), queue); |
| 518 | assert_eq!(cache.snapshot().unwrap(), local); | 513 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 519 | drop(cache); | 514 | drop(cache); |
| 520 | let cache = Replica::open(&path).unwrap(); | 515 | let cache = Replica::open(&path).unwrap(); |
| 521 | review(&cache, id, dependent, save_both).unwrap(); | 516 | review(&cache, id, dependent, save_both).unwrap(); |
| ... | @@ -590,7 +585,7 @@ fn a_new_native_wrap_reservation_requires_review_before_width_replacement() { | ... | @@ -590,7 +585,7 @@ fn a_new_native_wrap_reservation_requires_review_before_width_replacement() { |
| 590 | .unwrap(); | 585 | .unwrap(); |
| 591 | let mut server = Server::new(NATIVE); | 586 | let mut server = Server::new(NATIVE); |
| 592 | assert_eq!( | 587 | assert_eq!( |
| 593 | cache.sync_once(&mut server).unwrap(), | 588 | cache.sync_once(&mut server).unwrap().edit, |
| 594 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 589 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 595 | ); | 590 | ); |
| 596 | review(&cache, id, text_id, |page| { | 591 | review(&cache, id, text_id, |page| { |
crates/notebook/tests/sync_page.rs+166-71| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | //! Page creation reconciliation: dependent body saves, uncertain publication, anchor review, | 1 | //! Page creation reconciliation: dependent body saves, uncertain publication, anchor review, |
| 2 | //! and deterministic multi-actor schedules. | 2 | //! and deterministic multi-actor schedules. |
| 3 | 3 | ||
| 4 | use notebook::{ConflictKind, EditStatus, Operation, Recovery, Replica}; | 4 | use notebook::{ConflictKind, EditStatus, Recovery, Replica, Resolution}; |
| 5 | use onestore::op::SectionOp; | ||
| 5 | use onestore::{ | 6 | use onestore::{ |
| 6 | ExGuid, PageCreation, RevisionIndex, Store, | 7 | ExGuid, PageCreation, RevisionIndex, Store, |
| 7 | document::{Document, Kind}, | 8 | document::{Document, Kind}, |
| ... | @@ -49,47 +50,38 @@ fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() { | ... | @@ -49,47 +50,38 @@ fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() { |
| 49 | let path = directory.path().join(format!("{actor}.sqlite")); | 50 | let path = directory.path().join(format!("{actor}.sqlite")); |
| 50 | let cache = Replica::create(&path, &source).unwrap(); | 51 | let cache = Replica::create(&path, &source).unwrap(); |
| 51 | let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap(); | 52 | let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap(); |
| 52 | let id = cache.create_page(&source, &page).unwrap().unwrap(); | 53 | let id = section_op(&cache, SectionOp::Create(page.clone())); |
| 53 | let mut created = model_ops::page_of(&cache.snapshot().unwrap(), page.space()); | 54 | let mut created = cache.page(page.space()).unwrap(); |
| 54 | let body = body_outline(&mut created, "Body"); | 55 | let body = body_outline(&mut created, "Body"); |
| 55 | let save = cache | 56 | let save = model_ops::save_as(&cache, page.space(), &created, model_ops::AUTHOR) |
| 56 | .save( | ||
| 57 | &cache.snapshot().unwrap(), | ||
| 58 | page.space(), | ||
| 59 | &created, | ||
| 60 | model_ops::AUTHOR, | ||
| 61 | ) | ||
| 62 | .unwrap() | 57 | .unwrap() |
| 63 | .unwrap(); | 58 | .unwrap(); |
| 64 | assert_eq!( | 59 | let dependent = model_ops::save(&cache, body, |page| { |
| 65 | model_ops::save(&cache, body, |page| model_ops::replace_text( | 60 | model_ops::replace_text(page, body, 4..4, &format!(" {actor}")) |
| 66 | page, | 61 | }) |
| 67 | body, | 62 | .unwrap() |
| 68 | 4..4, | 63 | .unwrap(); |
| 69 | &format!(" {actor}") | 64 | assert!(id < save && save < dependent); |
| 70 | )) | ||
| 71 | .unwrap(), | ||
| 72 | Some(save), | ||
| 73 | "a dependent text edit coalesces into the unattempted body save" | ||
| 74 | ); | ||
| 75 | let original = cache.pending().unwrap(); | 65 | let original = cache.pending().unwrap(); |
| 76 | assert_eq!(original.len(), 2); | 66 | assert_eq!(original.len(), 3); |
| 77 | assert!( | 67 | assert!( |
| 78 | matches!(&original[0].operation, Operation::CreatePage(retained) if retained == &page) | 68 | matches!(&original[0].edit.ops[..], [onestore::op::Op::Section(SectionOp::Create(retained))] if retained == &page) |
| 79 | ); | 69 | ); |
| 80 | let local = cache.snapshot().unwrap(); | 70 | let local = cache.snapshot().unwrap(); |
| 81 | drop(cache); | 71 | drop(cache); |
| 82 | let cache = Replica::open(&path).unwrap(); | 72 | let cache = Replica::open(&path).unwrap(); |
| 83 | assert_eq!(cache.pending().unwrap(), original); | 73 | assert_eq!(cache.pending().unwrap(), original); |
| 84 | assert_eq!(cache.snapshot().unwrap(), local); | 74 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 85 | for edit in original { | ||
| 86 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 87 | Some((published, EditStatus::Published { .. })) if published == edit.id)); | ||
| 88 | } | ||
| 89 | assert!(matches!( | 75 | assert!(matches!( |
| 90 | cache.status(id).unwrap(), | 76 | cache.sync_once(&mut server).unwrap().edit, |
| 91 | Some(EditStatus::Published { .. }) | 77 | Some((published, EditStatus::Published { .. })) if published == dependent |
| 92 | )); | 78 | )); |
| 79 | for edit in original { | ||
| 80 | assert!(matches!( | ||
| 81 | cache.status(edit.id).unwrap(), | ||
| 82 | Some(EditStatus::Published { .. }) | ||
| 83 | )); | ||
| 84 | } | ||
| 93 | expected.push((page.space(), page.object(), body, format!("Body {actor}"))); | 85 | expected.push((page.space(), page.object(), body, format!("Body {actor}"))); |
| 94 | } | 86 | } |
| 95 | let store = Store::parse(&server.durable).unwrap(); | 87 | let store = Store::parse(&server.durable).unwrap(); |
| ... | @@ -103,7 +95,7 @@ fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() { | ... | @@ -103,7 +95,7 @@ fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() { |
| 103 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | 95 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 104 | assert!(matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text == expected)); | 96 | assert!(matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text == expected)); |
| 105 | } | 97 | } |
| 106 | assert_eq!(server.publications, 24); | 98 | assert_eq!(server.publications, 12); |
| 107 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_OUTPUT") { | 99 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_OUTPUT") { |
| 108 | std::fs::create_dir(&output).unwrap(); | 100 | std::fs::create_dir(&output).unwrap(); |
| 109 | std::fs::write( | 101 | std::fs::write( |
| ... | @@ -127,7 +119,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() { | ... | @@ -127,7 +119,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() { |
| 127 | let path = directory.path().join("pages.sqlite"); | 119 | let path = directory.path().join("pages.sqlite"); |
| 128 | let cache = Replica::create(&path, &source).unwrap(); | 120 | let cache = Replica::create(&path, &source).unwrap(); |
| 129 | let page = PageCreation::new(None, Some("Created"), "Author").unwrap(); | 121 | let page = PageCreation::new(None, Some("Created"), "Author").unwrap(); |
| 130 | let id = cache.create_page(&source, &page).unwrap().unwrap(); | 122 | let id = section_op(&cache, SectionOp::Create(page.clone())); |
| 131 | let local = cache.snapshot().unwrap(); | 123 | let local = cache.snapshot().unwrap(); |
| 132 | let mut server = Server::new(&source); | 124 | let mut server = Server::new(&source); |
| 133 | server.fault = fault; | 125 | server.fault = fault; |
| ... | @@ -145,7 +137,11 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() { | ... | @@ -145,7 +137,11 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() { |
| 145 | drop(cache); | 137 | drop(cache); |
| 146 | let db = rusqlite::Connection::open(&path).unwrap(); | 138 | let db = rusqlite::Connection::open(&path).unwrap(); |
| 147 | let encoded: String = db | 139 | let encoded: String = db |
| 148 | .query_row("SELECT revisions FROM attempt", [], |row| row.get(0)) | 140 | .query_row( |
| 141 | "SELECT revisions FROM batches WHERE attempted=1", | ||
| 142 | [], | ||
| 143 | |row| row.get(0), | ||
| 144 | ) | ||
| 149 | .unwrap(); | 145 | .unwrap(); |
| 150 | let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); | 146 | let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); |
| 151 | assert_eq!(revisions.len(), 2); | 147 | assert_eq!(revisions.len(), 2); |
| ... | @@ -153,7 +149,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() { | ... | @@ -153,7 +149,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() { |
| 153 | drop(db); | 149 | drop(db); |
| 154 | let cache = Replica::open(&path).unwrap(); | 150 | let cache = Replica::open(&path).unwrap(); |
| 155 | let observed = server.visible.clone(); | 151 | let observed = server.visible.clone(); |
| 156 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | 152 | let result = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 157 | let visible = matches!(fault, Fault::UnknownAfter | Fault::PanicAfter); | 153 | let visible = matches!(fault, Fault::UnknownAfter | Fault::PanicAfter); |
| 158 | assert_eq!(server.publications, 1); | 154 | assert_eq!(server.publications, 1); |
| 159 | assert_eq!(server.confirmations, usize::from(visible)); | 155 | assert_eq!(server.confirmations, usize::from(visible)); |
| ... | @@ -164,7 +160,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() { | ... | @@ -164,7 +160,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() { |
| 164 | assert!(observed[252..] == server.durable[252..]); | 160 | assert!(observed[252..] == server.durable[252..]); |
| 165 | } else { | 161 | } else { |
| 166 | assert_eq!(result, (id, attempted.clone())); | 162 | assert_eq!(result, (id, attempted.clone())); |
| 167 | assert_eq!(cache.snapshot().unwrap(), local); | 163 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 168 | } | 164 | } |
| 169 | } | 165 | } |
| 170 | } | 166 | } |
| ... | @@ -176,7 +172,7 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() { | ... | @@ -176,7 +172,7 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() { |
| 176 | let path = directory.path().join("pages.sqlite"); | 172 | let path = directory.path().join("pages.sqlite"); |
| 177 | let cache = Replica::create(&path, &source).unwrap(); | 173 | let cache = Replica::create(&path, &source).unwrap(); |
| 178 | let page = PageCreation::new(None, None, "Author").unwrap(); | 174 | let page = PageCreation::new(None, None, "Author").unwrap(); |
| 179 | let id = cache.create_page(&source, &page).unwrap().unwrap(); | 175 | let id = section_op(&cache, SectionOp::Create(page.clone())); |
| 180 | let mut server = Server::new(&source); | 176 | let mut server = Server::new(&source); |
| 181 | server.fault = Fault::UnknownAfter; | 177 | server.fault = Fault::UnknownAfter; |
| 182 | assert!(cache.sync_once(&mut server).is_err()); | 178 | assert!(cache.sync_once(&mut server).is_err()); |
| ... | @@ -184,7 +180,11 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() { | ... | @@ -184,7 +180,11 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() { |
| 184 | drop(cache); | 180 | drop(cache); |
| 185 | let db = rusqlite::Connection::open(&path).unwrap(); | 181 | let db = rusqlite::Connection::open(&path).unwrap(); |
| 186 | let encoded: String = db | 182 | let encoded: String = db |
| 187 | .query_row("SELECT revisions FROM attempt", [], |row| row.get(0)) | 183 | .query_row( |
| 184 | "SELECT revisions FROM batches WHERE attempted=1", | ||
| 185 | [], | ||
| 186 | |row| row.get(0), | ||
| 187 | ) | ||
| 188 | .unwrap(); | 188 | .unwrap(); |
| 189 | let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); | 189 | let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); |
| 190 | drop(db); | 190 | drop(db); |
| ... | @@ -222,21 +222,21 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() { | ... | @@ -222,21 +222,21 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() { |
| 222 | ); | 222 | ); |
| 223 | let cache = Replica::open(&path).unwrap(); | 223 | let cache = Replica::open(&path).unwrap(); |
| 224 | assert_eq!( | 224 | assert_eq!( |
| 225 | cache.sync_once(&mut server).unwrap(), | 225 | cache.sync_once(&mut server).unwrap().edit, |
| 226 | Some((id, attempted.clone())) | 226 | Some((id, attempted.clone())) |
| 227 | ); | 227 | ); |
| 228 | assert_eq!(server.confirmations, 0); | 228 | assert_eq!(server.confirmations, 0); |
| 229 | assert_eq!(server.publications, 1); | 229 | assert_eq!(server.publications, 1); |
| 230 | server.visible = complete; | 230 | server.visible = complete; |
| 231 | assert!(matches!( | 231 | assert!(matches!( |
| 232 | cache.sync_once(&mut server).unwrap(), | 232 | cache.sync_once(&mut server).unwrap().edit, |
| 233 | Some((_, EditStatus::Published { .. })) | 233 | Some((_, EditStatus::Published { .. })) |
| 234 | )); | 234 | )); |
| 235 | assert_eq!(server.publications, 1); | 235 | assert_eq!(server.publications, 1); |
| 236 | } | 236 | } |
| 237 | 237 | ||
| 238 | #[test] | 238 | #[test] |
| 239 | fn changed_page_anchor_requires_review_without_regenerating_dependent_identities() { | 239 | fn changed_page_anchor_conflicts_until_kept_without_regenerating_dependent_identities() { |
| 240 | let source = include_bytes!("../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one"); | 240 | let source = include_bytes!("../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one"); |
| 241 | let remote = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); | 241 | let remote = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); |
| 242 | let store = Store::parse(source).unwrap(); | 242 | let store = Store::parse(source).unwrap(); |
| ... | @@ -246,48 +246,36 @@ fn changed_page_anchor_requires_review_without_regenerating_dependent_identities | ... | @@ -246,48 +246,36 @@ fn changed_page_anchor_requires_review_without_regenerating_dependent_identities |
| 246 | let path = directory.path().join("pages.sqlite"); | 246 | let path = directory.path().join("pages.sqlite"); |
| 247 | let cache = Replica::create(&path, source).unwrap(); | 247 | let cache = Replica::create(&path, source).unwrap(); |
| 248 | let page = PageCreation::new(Some(pages[4].0), Some("Created"), "Author").unwrap(); | 248 | let page = PageCreation::new(Some(pages[4].0), Some("Created"), "Author").unwrap(); |
| 249 | let id = cache.create_page(source, &page).unwrap().unwrap(); | 249 | let id = section_op(&cache, SectionOp::Create(page.clone())); |
| 250 | let mut created = model_ops::page_of(&cache.snapshot().unwrap(), page.space()); | 250 | let mut created = cache.page(page.space()).unwrap(); |
| 251 | let body = body_outline(&mut created, "Retained body"); | 251 | let body = body_outline(&mut created, "Retained body"); |
| 252 | cache | 252 | let save = model_ops::save_as(&cache, page.space(), &created, "Author") |
| 253 | .save(&cache.snapshot().unwrap(), page.space(), &created, "Author") | ||
| 254 | .unwrap() | 253 | .unwrap() |
| 255 | .unwrap(); | 254 | .unwrap(); |
| 256 | let local = cache.snapshot().unwrap(); | 255 | let local = cache.snapshot().unwrap(); |
| 257 | let pending = cache.pending().unwrap(); | 256 | let pending = cache.pending().unwrap(); |
| 258 | let mut server = Server::new(remote); | 257 | let mut server = Server::new(remote); |
| 259 | assert_eq!( | 258 | assert_eq!( |
| 260 | cache.sync_once(&mut server).unwrap(), | 259 | cache.sync_once(&mut server).unwrap().edit, |
| 261 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | 260 | Some((save, EditStatus::Conflict(ConflictKind::StructureChanged))) |
| 262 | ); | 261 | ); |
| 263 | assert!( | 262 | cache.resolve(id, Resolution::Mine).unwrap(); |
| 264 | cache | ||
| 265 | .rebase_page_creation_conflict(id, source, remote, None) | ||
| 266 | .is_err() | ||
| 267 | ); | ||
| 268 | assert!( | ||
| 269 | cache | ||
| 270 | .rebase_page_creation_conflict(id, &local, source, None) | ||
| 271 | .is_err() | ||
| 272 | ); | ||
| 273 | cache | ||
| 274 | .rebase_page_creation_conflict(id, &local, remote, None) | ||
| 275 | .unwrap(); | ||
| 276 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 277 | assert_eq!(cache.pending().unwrap()[1], pending[1]); | 263 | assert_eq!(cache.pending().unwrap()[1], pending[1]); |
| 278 | let Operation::CreatePage(reviewed) = &cache.pending().unwrap()[0].operation else { | 264 | assert!(matches!( |
| 279 | panic!() | 265 | &cache.pending().unwrap()[0].edit.ops[..], |
| 280 | }; | 266 | [onestore::op::Op::Section(SectionOp::Create(reviewed))] if *reviewed == page.reposition(None).unwrap() |
| 281 | assert_eq!(*reviewed, page.reposition(None).unwrap()); | 267 | )); |
| 268 | assert_eq!( | ||
| 269 | model_ops::page_of(&cache.snapshot().unwrap(), page.space()), | ||
| 270 | model_ops::page_of(&local, page.space()) | ||
| 271 | ); | ||
| 282 | drop(cache); | 272 | drop(cache); |
| 283 | let cache = Replica::open(&path).unwrap(); | 273 | let cache = Replica::open(&path).unwrap(); |
| 284 | for _ in 0..2 { | 274 | assert!(matches!( |
| 285 | assert!(matches!( | 275 | cache.sync_once(&mut server).unwrap().edit, |
| 286 | cache.sync_once(&mut server).unwrap(), | 276 | Some((_, EditStatus::Published { .. })) |
| 287 | Some((_, EditStatus::Published { .. })) | 277 | )); |
| 288 | )); | 278 | assert_eq!(server.publications, 1); |
| 289 | } | ||
| 290 | assert_eq!(server.publications, 2); | ||
| 291 | let store = Store::parse(&server.durable).unwrap(); | 279 | let store = Store::parse(&server.durable).unwrap(); |
| 292 | let index = RevisionIndex::parse(&store).unwrap(); | 280 | let index = RevisionIndex::parse(&store).unwrap(); |
| 293 | let document = Document::parse(&index).unwrap(); | 281 | let document = Document::parse(&index).unwrap(); |
| ... | @@ -296,3 +284,110 @@ fn changed_page_anchor_requires_review_without_regenerating_dependent_identities | ... | @@ -296,3 +284,110 @@ fn changed_page_anchor_requires_review_without_regenerating_dependent_identities |
| 296 | assert!(matches!(&view.nodes[&body].kind, | 284 | assert!(matches!(&view.nodes[&body].kind, |
| 297 | Kind::RichText { text, .. } if text == "Retained body")); | 285 | Kind::RichText { text, .. } if text == "Retained body")); |
| 298 | } | 286 | } |
| 287 | |||
| 288 | #[test] | ||
| 289 | fn 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 | |||
| 391 | fn pages_of(cache: &Replica) -> Vec<(ExGuid, String, u32)> { | ||
| 392 | cache.pages().unwrap() | ||
| 393 | } |
crates/notebook/tests/sync_pages.rs+102-124| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | //! Page batch reconciliation: atomic batches with dependent body saves, competing moves and | 1 | //! Page batch reconciliation: atomic batches with dependent body saves, competing moves and |
| 2 | //! indentation, uncertain batches, and native page movement fixtures. | 2 | //! indentation, uncertain batches, and native page movement fixtures. |
| 3 | 3 | ||
| 4 | use notebook::{ConflictKind, EditStatus, Operation, Recovery, Replica}; | 4 | use notebook::{ConflictKind, EditStatus, Recovery, Replica, Resolution}; |
| 5 | use onestore::op::{Op, SectionOp}; | ||
| 5 | use onestore::{ | 6 | use onestore::{ |
| 6 | ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, | 7 | ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, |
| 7 | document::{Document, Format, Kind, Layout}, | 8 | document::{Document, Format, Kind, Layout}, |
| ... | @@ -117,8 +118,7 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() { | ... | @@ -117,8 +118,7 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() { |
| 117 | .iter() | 118 | .iter() |
| 118 | .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap()) | 119 | .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap()) |
| 119 | .collect(); | 120 | .collect(); |
| 120 | let id = cache.pages(SOURCE, &edits).unwrap().unwrap(); | 121 | let id = section_op(&cache, SectionOp::Pages(edits.to_vec())); |
| 121 | assert!(cache.pages(SOURCE, &edits).is_err()); | ||
| 122 | let mut expected_text = texts(SOURCE); | 122 | let mut expected_text = texts(SOURCE); |
| 123 | let (&(sid, oid), original) = expected_text | 123 | let (&(sid, oid), original) = expected_text |
| 124 | .iter() | 124 | .iter() |
| ... | @@ -132,17 +132,22 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() { | ... | @@ -132,17 +132,22 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() { |
| 132 | .unwrap() | 132 | .unwrap() |
| 133 | .unwrap(); | 133 | .unwrap(); |
| 134 | let queue = cache.pending().unwrap(); | 134 | let queue = cache.pending().unwrap(); |
| 135 | assert!(matches!(&queue[0].operation, Operation::Pages(batch) if batch.edits == edits)); | 135 | assert!( |
| 136 | matches!(&queue[0].edit.ops[..], [Op::Section(SectionOp::Pages(batch))] if *batch == edits) | ||
| 137 | ); | ||
| 136 | let local = cache.snapshot().unwrap(); | 138 | let local = cache.snapshot().unwrap(); |
| 137 | drop(cache); | 139 | drop(cache); |
| 138 | let cache = Replica::open(&path).unwrap(); | 140 | let cache = Replica::open(&path).unwrap(); |
| 139 | assert_eq!(cache.pending().unwrap(), queue); | 141 | assert_eq!(cache.pending().unwrap(), queue); |
| 140 | assert_eq!(cache.snapshot().unwrap(), local); | 142 | assert_eq!( |
| 143 | server::pages(&cache.snapshot().unwrap()), | ||
| 144 | server::pages(&local) | ||
| 145 | ); | ||
| 141 | let mut server = Server::new(SOURCE); | 146 | let mut server = Server::new(SOURCE); |
| 142 | for edit in queue { | 147 | assert!(matches!( |
| 143 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | 148 | cache.sync_once(&mut server).unwrap().edit, |
| 144 | Some((published, EditStatus::Published { .. })) if published == edit.id)); | 149 | Some((_, EditStatus::Published { .. })) |
| 145 | } | 150 | )); |
| 146 | let expected = [ | 151 | let expected = [ |
| 147 | before[..3].to_vec(), | 152 | before[..3].to_vec(), |
| 148 | before[6..].to_vec(), | 153 | before[6..].to_vec(), |
| ... | @@ -155,7 +160,7 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() { | ... | @@ -155,7 +160,7 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() { |
| 155 | cache.status(id).unwrap(), | 160 | cache.status(id).unwrap(), |
| 156 | Some(EditStatus::Published { .. }) | 161 | Some(EditStatus::Published { .. }) |
| 157 | )); | 162 | )); |
| 158 | assert_eq!(server.publications, 2); | 163 | assert_eq!(server.publications, 1); |
| 159 | } | 164 | } |
| 160 | 165 | ||
| 161 | #[test] | 166 | #[test] |
| ... | @@ -164,10 +169,10 @@ fn indentation_only_edits_preserve_remote_page_movement() { | ... | @@ -164,10 +169,10 @@ fn indentation_only_edits_preserve_remote_page_movement() { |
| 164 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | 169 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); |
| 165 | let before = order(SOURCE); | 170 | let before = order(SOURCE); |
| 166 | let sid = before[4].0; | 171 | let sid = before[4].0; |
| 167 | let id = cache | 172 | let id = section_op( |
| 168 | .pages(SOURCE, &[PageEdit::set_level(sid, 3).unwrap()]) | 173 | &cache, |
| 169 | .unwrap() | 174 | SectionOp::Pages([PageEdit::set_level(sid, 3).unwrap()].to_vec()), |
| 170 | .unwrap(); | 175 | ); |
| 171 | let mut server = Server::new(SOURCE); | 176 | let mut server = Server::new(SOURCE); |
| 172 | PreparedEdit::pages( | 177 | PreparedEdit::pages( |
| 173 | SOURCE, | 178 | SOURCE, |
| ... | @@ -178,22 +183,19 @@ fn indentation_only_edits_preserve_remote_page_movement() { | ... | @@ -178,22 +183,19 @@ fn indentation_only_edits_preserve_remote_page_movement() { |
| 178 | .unwrap(); | 183 | .unwrap(); |
| 179 | let mut expected = order(&server.durable); | 184 | let mut expected = order(&server.durable); |
| 180 | expected.iter_mut().find(|p| p.0 == sid).unwrap().1 = 3; | 185 | expected.iter_mut().find(|p| p.0 == sid).unwrap().1 = 3; |
| 181 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | 186 | assert!(matches!(cache.sync_once(&mut server).unwrap().edit, |
| 182 | Some((published, EditStatus::Published { .. })) if published == id)); | 187 | Some((published, EditStatus::Published { .. })) if published == id)); |
| 183 | assert_eq!(order(&server.durable), expected); | 188 | assert_eq!(order(&server.durable), expected); |
| 184 | } | 189 | } |
| 185 | 190 | ||
| 186 | #[test] | 191 | #[test] |
| 187 | fn competing_page_moves_require_current_review_and_retain_intent_identities() { | 192 | fn competing_page_moves_conflict_until_mine_is_kept_with_its_identities() { |
| 188 | let directory = tempfile::tempdir().unwrap(); | 193 | let directory = tempfile::tempdir().unwrap(); |
| 189 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | 194 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); |
| 190 | let pages = order(SOURCE); | 195 | let pages = order(SOURCE); |
| 191 | let sid = pages[6].0; | 196 | let sid = pages[6].0; |
| 192 | let original = PageEdit::move_to(sid, None, 1).unwrap(); | 197 | let original = PageEdit::move_to(sid, None, 1).unwrap(); |
| 193 | let id = cache | 198 | let id = section_op(&cache, SectionOp::Pages(vec![original.clone()])); |
| 194 | .pages(SOURCE, std::slice::from_ref(&original)) | ||
| 195 | .unwrap() | ||
| 196 | .unwrap(); | ||
| 197 | let mut server = Server::new(SOURCE); | 199 | let mut server = Server::new(SOURCE); |
| 198 | PreparedEdit::pages( | 200 | PreparedEdit::pages( |
| 199 | SOURCE, | 201 | SOURCE, |
| ... | @@ -205,37 +207,20 @@ fn competing_page_moves_require_current_review_and_retain_intent_identities() { | ... | @@ -205,37 +207,20 @@ fn competing_page_moves_require_current_review_and_retain_intent_identities() { |
| 205 | let remote = server.visible.clone(); | 207 | let remote = server.visible.clone(); |
| 206 | let local = cache.snapshot().unwrap(); | 208 | let local = cache.snapshot().unwrap(); |
| 207 | assert_eq!( | 209 | assert_eq!( |
| 208 | cache.sync_once(&mut server).unwrap(), | 210 | cache.sync_once(&mut server).unwrap().edit, |
| 209 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | 211 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) |
| 210 | ); | 212 | ); |
| 211 | assert_eq!(server.publications, 0); | 213 | assert_eq!(server.publications, 0); |
| 212 | assert_eq!(cache.snapshot().unwrap(), local); | 214 | assert_eq!( |
| 213 | let revised = original | 215 | server::pages(&cache.snapshot().unwrap()), |
| 214 | .reposition(PagePosition::Before(Some(pages[2].0)), 1) | 216 | server::pages(&local) |
| 215 | .unwrap(); | ||
| 216 | let fresh = PageEdit::move_to(sid, Some(pages[2].0), 1).unwrap(); | ||
| 217 | assert!( | ||
| 218 | cache | ||
| 219 | .rebase_pages_conflict(id, &local, &remote, &[fresh]) | ||
| 220 | .is_err() | ||
| 221 | ); | ||
| 222 | assert!( | ||
| 223 | cache | ||
| 224 | .rebase_pages_conflict(id, SOURCE, &remote, std::slice::from_ref(&revised)) | ||
| 225 | .is_err() | ||
| 226 | ); | ||
| 227 | assert!( | ||
| 228 | cache | ||
| 229 | .rebase_pages_conflict(id, &local, SOURCE, std::slice::from_ref(&revised)) | ||
| 230 | .is_err() | ||
| 231 | ); | 217 | ); |
| 232 | cache | 218 | // Keeping mine moves the page last again, from where the remote put it. |
| 233 | .rebase_pages_conflict(id, &local, &remote, std::slice::from_ref(&revised)) | 219 | cache.resolve(id, Resolution::Mine).unwrap(); |
| 234 | .unwrap(); | 220 | assert!(matches!(&cache.pending().unwrap()[0].edit.ops[..], |
| 235 | assert!(matches!(&cache.pending().unwrap()[0].operation, | 221 | [Op::Section(SectionOp::Pages(batch))] if *batch == [original.clone()])); |
| 236 | Operation::Pages(batch) if batch.edits == [revised.clone()])); | 222 | let expected = PreparedEdit::pages(&remote, &[original]).unwrap(); |
| 237 | let expected = PreparedEdit::pages(&remote, &[revised]).unwrap(); | 223 | assert!(matches!(cache.sync_once(&mut server).unwrap().edit, |
| 238 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 239 | Some((published, EditStatus::Published { .. })) if published == id)); | 224 | Some((published, EditStatus::Published { .. })) if published == id)); |
| 240 | assert_eq!(order(&server.durable), order(expected.as_bytes())); | 225 | assert_eq!(order(&server.durable), order(expected.as_bytes())); |
| 241 | } | 226 | } |
| ... | @@ -245,16 +230,13 @@ fn one_competing_level_prevents_publication_of_the_whole_batch() { | ... | @@ -245,16 +230,13 @@ fn one_competing_level_prevents_publication_of_the_whole_batch() { |
| 245 | let directory = tempfile::tempdir().unwrap(); | 230 | let directory = tempfile::tempdir().unwrap(); |
| 246 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | 231 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); |
| 247 | let pages = order(SOURCE); | 232 | let pages = order(SOURCE); |
| 248 | let id = cache | 233 | let id = section_op( |
| 249 | .pages( | 234 | &cache, |
| 250 | SOURCE, | 235 | SectionOp::Pages(vec![ |
| 251 | &[ | 236 | PageEdit::set_level(pages[4].0, 3).unwrap(), |
| 252 | PageEdit::set_level(pages[4].0, 3).unwrap(), | 237 | PageEdit::set_level(pages[5].0, 2).unwrap(), |
| 253 | PageEdit::set_level(pages[5].0, 2).unwrap(), | 238 | ]), |
| 254 | ], | 239 | ); |
| 255 | ) | ||
| 256 | .unwrap() | ||
| 257 | .unwrap(); | ||
| 258 | let local = cache.snapshot().unwrap(); | 240 | let local = cache.snapshot().unwrap(); |
| 259 | let mut server = Server::new(SOURCE); | 241 | let mut server = Server::new(SOURCE); |
| 260 | PreparedEdit::pages(SOURCE, &[PageEdit::set_level(pages[5].0, 1).unwrap()]) | 242 | PreparedEdit::pages(SOURCE, &[PageEdit::set_level(pages[5].0, 1).unwrap()]) |
| ... | @@ -263,12 +245,15 @@ fn one_competing_level_prevents_publication_of_the_whole_batch() { | ... | @@ -263,12 +245,15 @@ fn one_competing_level_prevents_publication_of_the_whole_batch() { |
| 263 | .unwrap(); | 245 | .unwrap(); |
| 264 | let remote = server.durable.clone(); | 246 | let remote = server.durable.clone(); |
| 265 | assert_eq!( | 247 | assert_eq!( |
| 266 | cache.sync_once(&mut server).unwrap(), | 248 | cache.sync_once(&mut server).unwrap().edit, |
| 267 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | 249 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) |
| 268 | ); | 250 | ); |
| 269 | assert_eq!(server.publications, 0); | 251 | assert_eq!(server.publications, 0); |
| 270 | assert_eq!(server.durable, remote); | 252 | assert_eq!(server.durable, remote); |
| 271 | assert_eq!(cache.snapshot().unwrap(), local); | 253 | assert_eq!( |
| 254 | server::pages(&cache.snapshot().unwrap()), | ||
| 255 | server::pages(&local) | ||
| 256 | ); | ||
| 272 | } | 257 | } |
| 273 | 258 | ||
| 274 | #[test] | 259 | #[test] |
| ... | @@ -287,7 +272,7 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() { | ... | @@ -287,7 +272,7 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() { |
| 287 | PageEdit::set_level(pages[4].0, 3).unwrap(), | 272 | PageEdit::set_level(pages[4].0, 3).unwrap(), |
| 288 | PageEdit::set_level(pages[5].0, 2).unwrap(), | 273 | PageEdit::set_level(pages[5].0, 2).unwrap(), |
| 289 | ]; | 274 | ]; |
| 290 | let id = cache.pages(SOURCE, &edits).unwrap().unwrap(); | 275 | let id = section_op(&cache, SectionOp::Pages(edits.to_vec())); |
| 291 | let local = cache.snapshot().unwrap(); | 276 | let local = cache.snapshot().unwrap(); |
| 292 | let queue = cache.pending().unwrap(); | 277 | let queue = cache.pending().unwrap(); |
| 293 | let mut server = Server::new(SOURCE); | 278 | let mut server = Server::new(SOURCE); |
| ... | @@ -304,13 +289,12 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() { | ... | @@ -304,13 +289,12 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() { |
| 304 | let recovery = Recovery::open(&archive).unwrap(); | 289 | let recovery = Recovery::open(&archive).unwrap(); |
| 305 | assert_eq!(recovery.pending().unwrap(), queue); | 290 | assert_eq!(recovery.pending().unwrap(), queue); |
| 306 | assert_eq!(recovery.status(id).unwrap(), Some(attempted.clone())); | 291 | assert_eq!(recovery.status(id).unwrap(), Some(attempted.clone())); |
| 307 | assert_eq!(recovery.snapshot().unwrap(), local); | 292 | assert_eq!( |
| 308 | assert!( | 293 | server::pages(&recovery.snapshot().unwrap()), |
| 309 | cache | 294 | server::pages(&local) |
| 310 | .rebase_pages_conflict(id, &local, SOURCE, &edits) | ||
| 311 | .is_err() | ||
| 312 | ); | 295 | ); |
| 313 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | 296 | assert!(cache.resolve(id, Resolution::Mine).is_err()); |
| 297 | let result = cache.sync_once(&mut server).unwrap().edit.unwrap(); | ||
| 314 | assert_eq!(server.publications, 1); | 298 | assert_eq!(server.publications, 1); |
| 315 | if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) { | 299 | if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) { |
| 316 | assert!(matches!(result.1, EditStatus::Published { .. })); | 300 | assert!(matches!(result.1, EditStatus::Published { .. })); |
| ... | @@ -341,10 +325,10 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() { | ... | @@ -341,10 +325,10 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() { |
| 341 | let directory = tempfile::tempdir().unwrap(); | 325 | let directory = tempfile::tempdir().unwrap(); |
| 342 | let path = directory.path().join("pages.sqlite"); | 326 | let path = directory.path().join("pages.sqlite"); |
| 343 | let cache = Replica::create(&path, &source).unwrap(); | 327 | let cache = Replica::create(&path, &source).unwrap(); |
| 344 | let id = cache | 328 | let id = section_op( |
| 345 | .pages(&source, &[PageEdit::set_level(sid, 3).unwrap()]) | 329 | &cache, |
| 346 | .unwrap() | 330 | SectionOp::Pages([PageEdit::set_level(sid, 3).unwrap()].to_vec()), |
| 347 | .unwrap(); | 331 | ); |
| 348 | let mut server = Server::new(&source); | 332 | let mut server = Server::new(&source); |
| 349 | server.fault = Fault::UnknownAfter; | 333 | server.fault = Fault::UnknownAfter; |
| 350 | assert!(cache.sync_once(&mut server).is_err()); | 334 | assert!(cache.sync_once(&mut server).is_err()); |
| ... | @@ -352,7 +336,9 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() { | ... | @@ -352,7 +336,9 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() { |
| 352 | drop(cache); | 336 | drop(cache); |
| 353 | let db = rusqlite::Connection::open(&path).unwrap(); | 337 | let db = rusqlite::Connection::open(&path).unwrap(); |
| 354 | let encoded: String = db | 338 | let encoded: String = db |
| 355 | .query_row("SELECT revisions FROM attempt", [], |r| r.get(0)) | 339 | .query_row("SELECT revisions FROM batches WHERE attempted=1", [], |r| { |
| 340 | r.get(0) | ||
| 341 | }) | ||
| 356 | .unwrap(); | 342 | .unwrap(); |
| 357 | let proofs: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); | 343 | let proofs: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); |
| 358 | assert_eq!(proofs.len(), 2); | 344 | assert_eq!(proofs.len(), 2); |
| ... | @@ -368,13 +354,13 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() { | ... | @@ -368,13 +354,13 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() { |
| 368 | let complete = server.visible.clone(); | 354 | let complete = server.visible.clone(); |
| 369 | server.visible = source; | 355 | server.visible = source; |
| 370 | assert_eq!( | 356 | assert_eq!( |
| 371 | cache.sync_once(&mut server).unwrap(), | 357 | cache.sync_once(&mut server).unwrap().edit, |
| 372 | Some((id, attempted.clone())) | 358 | Some((id, attempted.clone())) |
| 373 | ); | 359 | ); |
| 374 | assert_eq!(server.confirmations, 0); | 360 | assert_eq!(server.confirmations, 0); |
| 375 | server.visible = complete; | 361 | server.visible = complete; |
| 376 | assert!(matches!( | 362 | assert!(matches!( |
| 377 | cache.sync_once(&mut server).unwrap(), | 363 | cache.sync_once(&mut server).unwrap().edit, |
| 378 | Some((_, EditStatus::Published { .. })) | 364 | Some((_, EditStatus::Published { .. })) |
| 379 | )); | 365 | )); |
| 380 | assert_eq!(server.publications, 1); | 366 | assert_eq!(server.publications, 1); |
| ... | @@ -386,16 +372,13 @@ fn an_explicit_anchor_is_retained_even_when_originally_in_place() { | ... | @@ -386,16 +372,13 @@ fn an_explicit_anchor_is_retained_even_when_originally_in_place() { |
| 386 | let directory = tempfile::tempdir().unwrap(); | 372 | let directory = tempfile::tempdir().unwrap(); |
| 387 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | 373 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); |
| 388 | let pages = order(SOURCE); | 374 | let pages = order(SOURCE); |
| 389 | let id = cache | 375 | let id = section_op( |
| 390 | .pages( | 376 | &cache, |
| 391 | SOURCE, | 377 | SectionOp::Pages(vec![ |
| 392 | &[ | 378 | PageEdit::move_to(pages[6].0, Some(pages[7].0), 1).unwrap(), |
| 393 | PageEdit::move_to(pages[6].0, Some(pages[7].0), 1).unwrap(), | 379 | PageEdit::set_level(pages[4].0, 3).unwrap(), |
| 394 | PageEdit::set_level(pages[4].0, 3).unwrap(), | 380 | ]), |
| 395 | ], | 381 | ); |
| 396 | ) | ||
| 397 | .unwrap() | ||
| 398 | .unwrap(); | ||
| 399 | let mut server = Server::new(SOURCE); | 382 | let mut server = Server::new(SOURCE); |
| 400 | PreparedEdit::pages( | 383 | PreparedEdit::pages( |
| 401 | SOURCE, | 384 | SOURCE, |
| ... | @@ -405,7 +388,7 @@ fn an_explicit_anchor_is_retained_even_when_originally_in_place() { | ... | @@ -405,7 +388,7 @@ fn an_explicit_anchor_is_retained_even_when_originally_in_place() { |
| 405 | .commit(&mut server) | 388 | .commit(&mut server) |
| 406 | .unwrap(); | 389 | .unwrap(); |
| 407 | assert_eq!( | 390 | assert_eq!( |
| 408 | cache.sync_once(&mut server).unwrap(), | 391 | cache.sync_once(&mut server).unwrap().edit, |
| 409 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | 392 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) |
| 410 | ); | 393 | ); |
| 411 | assert_eq!(server.publications, 0); | 394 | assert_eq!(server.publications, 0); |
| ... | @@ -416,10 +399,10 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() { | ... | @@ -416,10 +399,10 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() { |
| 416 | let directory = tempfile::tempdir().unwrap(); | 399 | let directory = tempfile::tempdir().unwrap(); |
| 417 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | 400 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); |
| 418 | let sid = order(SOURCE)[4].0; | 401 | let sid = order(SOURCE)[4].0; |
| 419 | let id = cache | 402 | let id = section_op( |
| 420 | .pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()]) | 403 | &cache, |
| 421 | .unwrap() | 404 | SectionOp::Pages([PageEdit::set_level(sid, 1).unwrap()].to_vec()), |
| 422 | .unwrap(); | 405 | ); |
| 423 | let mut server = Server::new(SOURCE); | 406 | let mut server = Server::new(SOURCE); |
| 424 | PreparedEdit::pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()]) | 407 | PreparedEdit::pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()]) |
| 425 | .unwrap() | 408 | .unwrap() |
| ... | @@ -429,7 +412,7 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() { | ... | @@ -429,7 +412,7 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() { |
| 429 | assert!(cache.sync_once(&mut server).is_err()); | 412 | assert!(cache.sync_once(&mut server).is_err()); |
| 430 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | 413 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); |
| 431 | assert_eq!(server.publications, 0); | 414 | assert_eq!(server.publications, 0); |
| 432 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | 415 | assert!(matches!(cache.sync_once(&mut server).unwrap().edit, |
| 433 | Some((published, EditStatus::Published { .. })) if published == id)); | 416 | Some((published, EditStatus::Published { .. })) if published == id)); |
| 434 | assert_eq!(server.publications, 0); | 417 | assert_eq!(server.publications, 0); |
| 435 | assert_eq!(server.confirmations, 2); | 418 | assert_eq!(server.confirmations, 2); |
| ... | @@ -464,7 +447,7 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { | ... | @@ -464,7 +447,7 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { |
| 464 | .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap()) | 447 | .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap()) |
| 465 | .collect() | 448 | .collect() |
| 466 | }; | 449 | }; |
| 467 | let id = cache.pages(SOURCE, &edits).unwrap().unwrap(); | 450 | let id = section_op(&cache, SectionOp::Pages(edits.to_vec())); |
| 468 | let text_id = model_ops::save(&cache, text_object, |page| { | 451 | let text_id = model_ops::save(&cache, text_object, |page| { |
| 469 | model_ops::replace_text(page, text_object, 0..0, "Offline ") | 452 | model_ops::replace_text(page, text_object, 0..0, "Offline ") |
| 470 | }) | 453 | }) |
| ... | @@ -484,8 +467,11 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { | ... | @@ -484,8 +467,11 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { |
| 484 | "02-promoted-parent" | "03-selected-group-move" | "06-promoted-level-two" | 467 | "02-promoted-parent" | "03-selected-group-move" | "06-promoted-level-two" |
| 485 | ) | 468 | ) |
| 486 | }; | 469 | }; |
| 487 | let first = cache.sync_once(&mut server).unwrap().unwrap(); | 470 | let first = if expected_conflict { |
| 488 | assert_eq!(first.0, id); | 471 | cache.sync_once(&mut server).unwrap().edit.unwrap() |
| 472 | } else { | ||
| 473 | (text_id, EditStatus::Pending) | ||
| 474 | }; | ||
| 489 | let mut reviewed = edits.clone(); | 475 | let mut reviewed = edits.clone(); |
| 490 | if expected_conflict { | 476 | if expected_conflict { |
| 491 | assert_eq!( | 477 | assert_eq!( |
| ... | @@ -494,26 +480,25 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { | ... | @@ -494,26 +480,25 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { |
| 494 | "{mode}:{phase}" | 480 | "{mode}:{phase}" |
| 495 | ); | 481 | ); |
| 496 | assert_eq!(server.publications, 0); | 482 | assert_eq!(server.publications, 0); |
| 497 | assert_eq!(cache.snapshot().unwrap(), local); | 483 | assert_eq!( |
| 498 | if mode == "indent" && phase == "08-collapsed-group-move" { | 484 | server::pages(&cache.snapshot().unwrap()), |
| 499 | reviewed[0] = reviewed[0].reposition(PagePosition::Keep, 1).unwrap(); | 485 | server::pages(&local) |
| 500 | } | ||
| 501 | cache | ||
| 502 | .rebase_pages_conflict(id, &local, &native, &reviewed) | ||
| 503 | .unwrap(); | ||
| 504 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 505 | Some((published, EditStatus::Published { .. })) if published == id)); | ||
| 506 | } else { | ||
| 507 | assert!( | ||
| 508 | matches!(first.1, EditStatus::Published { .. }), | ||
| 509 | "{mode}:{phase}" | ||
| 510 | ); | 486 | ); |
| 487 | // Keeping mine drops the moves that no longer apply. | ||
| 488 | cache.resolve(id, Resolution::Mine).unwrap(); | ||
| 489 | reviewed = match &cache.pending().unwrap()[0].edit.ops[..] { | ||
| 490 | [Op::Section(SectionOp::Pages(kept))] => kept.clone(), | ||
| 491 | [] => Vec::new(), | ||
| 492 | other => panic!("{other:?}"), | ||
| 493 | }; | ||
| 511 | } | 494 | } |
| 495 | assert!( | ||
| 496 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((published, EditStatus::Published { .. })) if published == text_id), | ||
| 497 | "{mode}:{phase}" | ||
| 498 | ); | ||
| 512 | counts[usize::from(expected_conflict)] += 1; | 499 | counts[usize::from(expected_conflict)] += 1; |
| 513 | drop(cache); | 500 | drop(cache); |
| 514 | let cache = Replica::open(&path).unwrap(); | 501 | let cache = Replica::open(&path).unwrap(); |
| 515 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 516 | Some((published, EditStatus::Published { .. })) if published == text_id)); | ||
| 517 | assert!(cache.pending().unwrap().is_empty()); | 502 | assert!(cache.pending().unwrap().is_empty()); |
| 518 | let mut expected = order(&native); | 503 | let mut expected = order(&native); |
| 519 | for edit in &reviewed { | 504 | for edit in &reviewed { |
| ... | @@ -529,10 +514,7 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { | ... | @@ -529,10 +514,7 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { |
| 529 | } | 514 | } |
| 530 | assert_eq!(order(&server.durable), expected, "{mode}:{phase}"); | 515 | assert_eq!(order(&server.durable), expected, "{mode}:{phase}"); |
| 531 | assert_eq!(texts(&server.durable), expected_text, "{mode}:{phase}"); | 516 | assert_eq!(texts(&server.durable), expected_text, "{mode}:{phase}"); |
| 532 | assert_eq!( | 517 | assert_eq!(server.publications, 1); |
| 533 | server.publications, | ||
| 534 | 1 + usize::from(expected != order(&native)) | ||
| 535 | ); | ||
| 536 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_EDIT_OUTPUT") { | 518 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_EDIT_OUTPUT") { |
| 537 | let output = std::path::Path::new(&output); | 519 | let output = std::path::Path::new(&output); |
| 538 | assert!(output.is_absolute()); | 520 | assert!(output.is_absolute()); |
| ... | @@ -580,13 +562,12 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { | ... | @@ -580,13 +562,12 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { |
| 580 | PageEdit::move_to(group[1].space(), Some(group[0].space()), 1).unwrap(), | 562 | PageEdit::move_to(group[1].space(), Some(group[0].space()), 1).unwrap(), |
| 581 | PageEdit::set_level(group[2].space(), 2).unwrap(), | 563 | PageEdit::set_level(group[2].space(), 2).unwrap(), |
| 582 | ]; | 564 | ]; |
| 583 | cache.pages(&source, &edits).unwrap().unwrap(); | 565 | section_op(&cache, SectionOp::Pages(edits.to_vec())); |
| 584 | let body = format!("Actor {actor} 🦀 e\u{301}"); | 566 | let body = format!("Actor {actor} 🦀 e\u{301}"); |
| 585 | let local = cache.snapshot().unwrap(); | 567 | let local = cache.snapshot().unwrap(); |
| 586 | let mut model = model_ops::page_of(&local, group[1].space()); | 568 | let mut model = model_ops::page_of(&local, group[1].space()); |
| 587 | let text = body_outline(&mut model, &body); | 569 | let text = body_outline(&mut model, &body); |
| 588 | cache | 570 | model_ops::save_as(&cache, group[1].space(), &model, "Author") |
| 589 | .save(&local, group[1].space(), &model, "Author") | ||
| 590 | .unwrap() | 571 | .unwrap() |
| 591 | .unwrap(); | 572 | .unwrap(); |
| 592 | expected.extend([ | 573 | expected.extend([ |
| ... | @@ -601,18 +582,18 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { | ... | @@ -601,18 +582,18 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { |
| 601 | for (path, queue) in &queues { | 582 | for (path, queue) in &queues { |
| 602 | let cache = Replica::open(path).unwrap(); | 583 | let cache = Replica::open(path).unwrap(); |
| 603 | assert_eq!(cache.pending().unwrap(), *queue); | 584 | assert_eq!(cache.pending().unwrap(), *queue); |
| 604 | for edit in queue { | 585 | assert!(matches!( |
| 605 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | 586 | cache.sync_once(&mut server).unwrap().edit, |
| 606 | Some((published, EditStatus::Published { .. })) if published == edit.id)); | 587 | Some((_, EditStatus::Published { .. })) |
| 607 | } | 588 | )); |
| 608 | assert!(cache.pending().unwrap().is_empty()); | 589 | assert!(cache.pending().unwrap().is_empty()); |
| 609 | } | 590 | } |
| 610 | assert_eq!(server.publications, 24); | 591 | assert_eq!(server.publications, 12); |
| 611 | assert_eq!(order(&server.durable), expected); | 592 | assert_eq!(order(&server.durable), expected); |
| 612 | assert_eq!(texts(&server.durable), expected_text); | 593 | assert_eq!(texts(&server.durable), expected_text); |
| 613 | for (path, queue) in &queues { | 594 | for (path, queue) in &queues { |
| 614 | let cache = Replica::open(path).unwrap(); | 595 | let cache = Replica::open(path).unwrap(); |
| 615 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | 596 | assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); |
| 616 | assert_eq!(order(&cache.snapshot().unwrap()), expected); | 597 | assert_eq!(order(&cache.snapshot().unwrap()), expected); |
| 617 | for edit in queue { | 598 | for edit in queue { |
| 618 | assert!(matches!( | 599 | assert!(matches!( |
| ... | @@ -631,10 +612,7 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { | ... | @@ -631,10 +612,7 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { |
| 631 | .map(|(_, queue)| { | 612 | .map(|(_, queue)| { |
| 632 | queue | 613 | queue |
| 633 | .iter() | 614 | .iter() |
| 634 | .map(|edit| { | 615 | .map(|edit| serde_json::json!({"id": edit.id, "edit": edit.edit})) |
| 635 | serde_json::json!({"id": edit.id, "space": edit.space, | ||
| 636 | "operation": edit.operation}) | ||
| 637 | }) | ||
| 638 | .collect::<Vec<_>>() | 616 | .collect::<Vec<_>>() |
| 639 | }) | 617 | }) |
| 640 | .collect(); | 618 | .collect(); |
crates/notebook/tests/sync_paragraph.rs+59-54| ... | @@ -11,7 +11,7 @@ use std::path::Path; | ... | @@ -11,7 +11,7 @@ use std::path::Path; |
| 11 | 11 | ||
| 12 | #[path = "support/server.rs"] | 12 | #[path = "support/server.rs"] |
| 13 | mod server; | 13 | mod server; |
| 14 | use server::{Fault, Server}; | 14 | use server::{Fault, Server, pages}; |
| 15 | #[path = "support/model_ops.rs"] | 15 | #[path = "support/model_ops.rs"] |
| 16 | mod model_ops; | 16 | mod model_ops; |
| 17 | use model_ops::{AUTHOR, outlines_mut, page_of, replace_text}; | 17 | use model_ops::{AUTHOR, outlines_mut, page_of, replace_text}; |
| ... | @@ -178,16 +178,13 @@ fn save_page( | ... | @@ -178,16 +178,13 @@ fn save_page( |
| 178 | let source = cache.snapshot()?; | 178 | let source = cache.snapshot()?; |
| 179 | let (space, mut page) = holder(&source, text); | 179 | let (space, mut page) = holder(&source, text); |
| 180 | edit(&mut page); | 180 | edit(&mut page); |
| 181 | cache.save(&source, space, &page, AUTHOR) | 181 | model_ops::save_as(cache, space, &page, AUTHOR) |
| 182 | } | 182 | } |
| 183 | 183 | ||
| 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. |
| 185 | fn review_page(cache: &Replica, id: u64, text: ExGuid, edit: impl FnOnce(&mut Page)) { | 185 | fn review_page(cache: &Replica, id: u64, text: ExGuid, edit: impl FnOnce(&mut Page)) { |
| 186 | let local = cache.snapshot().unwrap(); | 186 | cache.resolve(id, notebook::Resolution::Theirs).unwrap(); |
| 187 | let remote = cache.remote_snapshot().unwrap(); | 187 | save_page(cache, text, edit).unwrap().unwrap(); |
| 188 | let (_, mut page) = holder(&remote, text); | ||
| 189 | edit(&mut page); | ||
| 190 | cache.review_page(id, &local, &remote, &page).unwrap(); | ||
| 191 | } | 188 | } |
| 192 | 189 | ||
| 193 | /// The styled text of an object anywhere on a page. | 190 | /// The styled text of an object anywhere on a page. |
| ... | @@ -246,18 +243,20 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) { | ... | @@ -246,18 +243,20 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) { |
| 246 | let mut cache = Replica::create(&path, BEFORE).unwrap(); | 243 | let mut cache = Replica::create(&path, BEFORE).unwrap(); |
| 247 | let mut after = original.clone(); | 244 | let mut after = original.clone(); |
| 248 | apply(&mut after); | 245 | apply(&mut after); |
| 249 | let id = cache.save(BEFORE, space, &after, AUTHOR).unwrap().unwrap(); | 246 | let id = model_ops::save_as(&cache, space, &after, AUTHOR) |
| 247 | .unwrap() | ||
| 248 | .unwrap(); | ||
| 250 | let pending = cache.pending().unwrap(); | 249 | let pending = cache.pending().unwrap(); |
| 251 | drop(cache); | 250 | drop(cache); |
| 252 | cache = Replica::open(&path).unwrap(); | 251 | cache = Replica::open(&path).unwrap(); |
| 253 | assert_eq!(cache.pending().unwrap(), pending); | 252 | assert_eq!(cache.pending().unwrap(), pending); |
| 254 | let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); | 253 | let (actual, status) = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 255 | assert_eq!(actual, id); | 254 | assert_eq!(actual, id); |
| 256 | let reviewed = matches!(status, EditStatus::Conflict(_)); | 255 | let reviewed = matches!(status, EditStatus::Conflict(_)); |
| 257 | let receipt = if reviewed { | 256 | let receipt = if reviewed { |
| 258 | assert_eq!(cache.pending().unwrap(), pending); | 257 | assert_eq!(cache.pending().unwrap(), pending); |
| 259 | review_page(&cache, id, left, apply); | 258 | review_page(&cache, id, left, apply); |
| 260 | cache.sync_once(&mut server).unwrap().unwrap().1 | 259 | cache.sync_once(&mut server).unwrap().edit.unwrap().1 |
| 261 | } else { | 260 | } else { |
| 262 | automatic += 1; | 261 | automatic += 1; |
| 263 | status | 262 | status |
| ... | @@ -293,7 +292,7 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) { | ... | @@ -293,7 +292,7 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) { |
| 293 | let dependent = save_page(&cache, target, |page| dependent_edit(page, target)) | 292 | let dependent = save_page(&cache, target, |page| dependent_edit(page, target)) |
| 294 | .unwrap() | 293 | .unwrap() |
| 295 | .unwrap(); | 294 | .unwrap(); |
| 296 | let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); | 295 | let (actual, status) = cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 297 | assert_eq!(actual, dependent); | 296 | assert_eq!(actual, dependent); |
| 298 | let EditStatus::Published { | 297 | let EditStatus::Published { |
| 299 | revision: dependent_revision, | 298 | revision: dependent_revision, |
| ... | @@ -309,7 +308,7 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) { | ... | @@ -309,7 +308,7 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) { |
| 309 | "receipt": revision, "dependent_receipt": dependent_revision}), | 308 | "receipt": revision, "dependent_receipt": dependent_revision}), |
| 310 | ); | 309 | ); |
| 311 | } | 310 | } |
| 312 | assert_eq!((recorded.len(), automatic), (16, 6)); | 311 | assert_eq!((recorded.len(), automatic), (16, 8)); |
| 313 | assert_eq!(server.publications, 32); | 312 | assert_eq!(server.publications, 32); |
| 314 | (server.durable, recorded) | 313 | (server.durable, recorded) |
| 315 | } | 314 | } |
| ... | @@ -321,19 +320,20 @@ fn native_keyboard_edits_merge_with_offline_splits_and_joins_or_survive_review() | ... | @@ -321,19 +320,20 @@ fn native_keyboard_edits_merge_with_offline_splits_and_joins_or_survive_review() |
| 321 | ("Split remote prefix", "automatic", vec!["Xab", "🦀Cd"]), | 320 | ("Split remote prefix", "automatic", vec!["Xab", "🦀Cd"]), |
| 322 | ("Split remote boundary", "automatic", vec!["abX", "🦀Cd"]), | 321 | ("Split remote boundary", "automatic", vec!["abX", "🦀Cd"]), |
| 323 | ("Split remote child", "reviewed", vec!["ab", "🦀Cd"]), | 322 | ("Split remote child", "reviewed", vec!["ab", "🦀Cd"]), |
| 324 | ("Split remote list", "automatic", vec!["ab", "🦀Cd"]), | 323 | // A split names copies of its paragraph's list nodes; the remote's new list has none. |
| 324 | ("Split remote list", "reviewed", vec!["ab", "🦀Cd"]), | ||
| 325 | ("Split remote tag", "automatic", vec!["ab", "🦀Cd"]), | 325 | ("Split remote tag", "automatic", vec!["ab", "🦀Cd"]), |
| 326 | ("Split remote format", "reviewed", vec!["ab", "🦀Cd"]), | 326 | ("Split remote format", "reviewed", vec!["ab", "🦀Cd"]), |
| 327 | ("Split remote sibling", "automatic", vec!["ab", "🦀Cd"]), | 327 | ("Split remote sibling", "automatic", vec!["ab", "🦀Cd"]), |
| 328 | ("Join remote prefix", "reviewed", vec!["Xab🦀CdRightY"]), | 328 | ("Join remote prefix", "reviewed", vec!["Xab🦀CdRightY"]), |
| 329 | ("Join left boundary", "reviewed", vec!["ab🦀CdXRight"]), | 329 | ("Join left boundary", "automatic", vec!["ab🦀CdXRight"]), |
| 330 | ("Join right boundary", "reviewed", vec!["ab🦀CdXRight"]), | 330 | ("Join right boundary", "reviewed", vec!["ab🦀CdXRight"]), |
| 331 | ("Join remote child", "reviewed", vec!["ab🦀CdRight"]), | 331 | ("Join remote child", "automatic", vec!["ab🦀CdRight"]), |
| 332 | ("Join remote list", "reviewed", vec!["ab🦀CdRight"]), | 332 | ("Join remote list", "reviewed", vec!["ab🦀CdRight"]), |
| 333 | ("Join remote tag", "reviewed", vec!["ab🦀CdRight"]), | 333 | ("Join remote tag", "reviewed", vec!["ab🦀CdRight"]), |
| 334 | ("Join remote format", "reviewed", vec!["ab🦀CdRight"]), | 334 | ("Join remote format", "reviewed", vec!["ab🦀CdRight"]), |
| 335 | ("Join remote sibling", "automatic", vec!["ab🦀CdRight"]), | 335 | ("Join remote sibling", "automatic", vec!["ab🦀CdRight"]), |
| 336 | ("Join empty adoption", "reviewed", vec!["XIight"]), | 336 | ("Join empty adoption", "automatic", vec!["XIight"]), |
| 337 | ]; | 337 | ]; |
| 338 | for (case, (name, outcome, prefix)) in cases.iter().zip(expected) { | 338 | for (case, (name, outcome, prefix)) in cases.iter().zip(expected) { |
| 339 | assert_eq!(case["case"], name); | 339 | assert_eq!(case["case"], name); |
| ... | @@ -363,17 +363,19 @@ fn a_native_text_object_replacement_conflicts_with_every_offline_split_and_join( | ... | @@ -363,17 +363,19 @@ fn a_native_text_object_replacement_conflicts_with_every_offline_split_and_join( |
| 363 | } else { | 363 | } else { |
| 364 | join_paragraphs(&mut after, left, body(&original)[1].text().unwrap().id); | 364 | join_paragraphs(&mut after, left, body(&original)[1].text().unwrap().id); |
| 365 | } | 365 | } |
| 366 | let id = cache.save(BEFORE, space, &after, AUTHOR).unwrap().unwrap(); | 366 | let id = model_ops::save_as(&cache, space, &after, AUTHOR) |
| 367 | .unwrap() | ||
| 368 | .unwrap(); | ||
| 367 | let local = cache.snapshot().unwrap(); | 369 | let local = cache.snapshot().unwrap(); |
| 368 | let pending = cache.pending().unwrap(); | 370 | let pending = cache.pending().unwrap(); |
| 369 | assert_eq!( | 371 | assert_eq!( |
| 370 | cache.sync_once(&mut server).unwrap(), | 372 | cache.sync_once(&mut server).unwrap().edit, |
| 371 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))), | 373 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))), |
| 372 | "{name}" | 374 | "{name}" |
| 373 | ); | 375 | ); |
| 374 | drop(cache); | 376 | drop(cache); |
| 375 | let cache = Replica::open(&path).unwrap(); | 377 | let cache = Replica::open(&path).unwrap(); |
| 376 | assert_eq!(cache.snapshot().unwrap(), local); | 378 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 377 | assert_eq!(cache.pending().unwrap(), pending); | 379 | assert_eq!(cache.pending().unwrap(), pending); |
| 378 | assert_eq!( | 380 | assert_eq!( |
| 379 | cache.status(id).unwrap(), | 381 | cache.status(id).unwrap(), |
| ... | @@ -448,6 +450,7 @@ fn offline_paragraphs(output: Option<&Path>) { | ... | @@ -448,6 +450,7 @@ fn offline_paragraphs(output: Option<&Path>) { |
| 448 | let cases = if split { &manifest["cases"] } else { &manifest }; | 450 | let cases = if split { &manifest["cases"] } else { &manifest }; |
| 449 | let mut entries = Vec::new(); | 451 | let mut entries = Vec::new(); |
| 450 | let mut refused: Vec<String> = Vec::new(); | 452 | let mut refused: Vec<String> = Vec::new(); |
| 453 | let mut reviewed: Vec<String> = Vec::new(); | ||
| 451 | for case in cases | 454 | for case in cases |
| 452 | .as_array() | 455 | .as_array() |
| 453 | .unwrap() | 456 | .unwrap() |
| ... | @@ -474,16 +477,10 @@ fn offline_paragraphs(output: Option<&Path>) { | ... | @@ -474,16 +477,10 @@ fn offline_paragraphs(output: Option<&Path>) { |
| 474 | continue; | 477 | continue; |
| 475 | }; | 478 | }; |
| 476 | let id = queued.unwrap(); | 479 | let id = queued.unwrap(); |
| 477 | entries.push( | 480 | let mut entry = serde_json::json!({"case": case["case"], "id": id, "space": space, |
| 478 | serde_json::json!({"case": case["case"], "id": id, "space": space, | 481 | "intent": intent, "remote_prefix": prefixed}); |
| 479 | "intent": intent, "remote_prefix": prefixed}), | ||
| 480 | ); | ||
| 481 | drop(cache); | 482 | drop(cache); |
| 482 | cache = Replica::open(&path).unwrap(); | 483 | cache = Replica::open(&path).unwrap(); |
| 483 | } | ||
| 484 | let mut reviewed: Vec<String> = Vec::new(); | ||
| 485 | for entry in &mut entries { | ||
| 486 | let id = entry["id"].as_u64().unwrap(); | ||
| 487 | let case = entry["case"].as_str().unwrap().to_owned(); | 484 | let case = entry["case"].as_str().unwrap().to_owned(); |
| 488 | let intent = entry["intent"].clone(); | 485 | let intent = entry["intent"].clone(); |
| 489 | let mut uncertain = id % 2 == 0; | 486 | let mut uncertain = id % 2 == 0; |
| ... | @@ -501,11 +498,20 @@ fn offline_paragraphs(output: Option<&Path>) { | ... | @@ -501,11 +498,20 @@ fn offline_paragraphs(output: Option<&Path>) { |
| 501 | drop(cache); | 498 | drop(cache); |
| 502 | cache = Replica::open(&path).unwrap(); | 499 | cache = Replica::open(&path).unwrap(); |
| 503 | } | 500 | } |
| 504 | Ok(Some((actual, EditStatus::Published { revision }))) => { | 501 | Ok(notebook::Synced { |
| 505 | assert_eq!(actual, id); | 502 | edit: Some((_, EditStatus::Published { revision })), |
| 503 | .. | ||
| 504 | }) => { | ||
| 505 | assert!(matches!( | ||
| 506 | cache.status(id).unwrap(), | ||
| 507 | Some(EditStatus::Published { .. }) | ||
| 508 | )); | ||
| 506 | break revision; | 509 | break revision; |
| 507 | } | 510 | } |
| 508 | Ok(Some((actual, EditStatus::Conflict(_)))) => { | 511 | Ok(notebook::Synced { |
| 512 | edit: Some((actual, EditStatus::Conflict(_))), | ||
| 513 | .. | ||
| 514 | }) => { | ||
| 509 | assert_eq!(actual, id); | 515 | assert_eq!(actual, id); |
| 510 | server.fault = Fault::None; | 516 | server.fault = Fault::None; |
| 511 | reviewed.push(case.clone()); | 517 | reviewed.push(case.clone()); |
| ... | @@ -518,16 +524,15 @@ fn offline_paragraphs(output: Option<&Path>) { | ... | @@ -518,16 +524,15 @@ fn offline_paragraphs(output: Option<&Path>) { |
| 518 | replay(page, &placed, split) | 524 | replay(page, &placed, split) |
| 519 | }); | 525 | }); |
| 520 | } | 526 | } |
| 521 | other => panic!("{name} {}: {other:?}", entry["case"]), | 527 | other => panic!("{name} {case}: {other:?}"), |
| 522 | } | 528 | } |
| 523 | }; | 529 | }; |
| 524 | entry["revision"] = serde_json::to_value(revision).unwrap(); | 530 | entry["revision"] = serde_json::to_value(revision).unwrap(); |
| 531 | entries.push(entry); | ||
| 525 | } | 532 | } |
| 526 | // A split inside a table cell the remote also changed overlaps at the cell. | ||
| 527 | let structural = ["Split cell".to_owned()]; | ||
| 528 | let unrepresentable = ["Join inherited styles".to_owned()]; | 533 | let unrepresentable = ["Join inherited styles".to_owned()]; |
| 529 | let expected: (usize, &[String], &[String]) = match name { | 534 | let expected: (usize, &[String], &[String]) = match name { |
| 530 | "splits" | "joins" => (12, &structural, &[]), | 535 | "splits" | "joins" => (12, &[], &[]), |
| 531 | "inheritance" => (4, &[], &unrepresentable), | 536 | "inheritance" => (4, &[], &unrepresentable), |
| 532 | _ => (3, &[], &[]), | 537 | _ => (3, &[], &[]), |
| 533 | }; | 538 | }; |
| ... | @@ -537,7 +542,7 @@ fn offline_paragraphs(output: Option<&Path>) { | ... | @@ -537,7 +542,7 @@ fn offline_paragraphs(output: Option<&Path>) { |
| 537 | "{name}" | 542 | "{name}" |
| 538 | ); | 543 | ); |
| 539 | assert_eq!(server.publications, entries.len()); | 544 | assert_eq!(server.publications, entries.len()); |
| 540 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | 545 | assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); |
| 541 | assert_eq!(cache.snapshot().unwrap(), server.durable); | 546 | assert_eq!(cache.snapshot().unwrap(), server.durable); |
| 542 | if output.is_some() { | 547 | if output.is_some() { |
| 543 | cache | 548 | cache |
| ... | @@ -585,7 +590,7 @@ fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() { | ... | @@ -585,7 +590,7 @@ fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() { |
| 585 | .unwrap() | 590 | .unwrap() |
| 586 | .unwrap(); | 591 | .unwrap(); |
| 587 | assert!(matches!( | 592 | assert!(matches!( |
| 588 | cache.sync_once(&mut server).unwrap(), | 593 | cache.sync_once(&mut server).unwrap().edit, |
| 589 | Some((_, EditStatus::Published { .. })) | 594 | Some((_, EditStatus::Published { .. })) |
| 590 | )); | 595 | )); |
| 591 | let published = page_of(&server.durable, space); | 596 | let published = page_of(&server.durable, space); |
| ... | @@ -598,7 +603,7 @@ fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() { | ... | @@ -598,7 +603,7 @@ fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() { |
| 598 | .unwrap() | 603 | .unwrap() |
| 599 | .unwrap(); | 604 | .unwrap(); |
| 600 | assert!(matches!( | 605 | assert!(matches!( |
| 601 | cache.sync_once(&mut server).unwrap(), | 606 | cache.sync_once(&mut server).unwrap().edit, |
| 602 | Some((_, EditStatus::Published { .. })) | 607 | Some((_, EditStatus::Published { .. })) |
| 603 | )); | 608 | )); |
| 604 | assert_eq!( | 609 | assert_eq!( |
| ... | @@ -623,7 +628,7 @@ fn a_split_merges_with_a_remote_edit_to_another_paragraph() { | ... | @@ -623,7 +628,7 @@ fn a_split_merges_with_a_remote_edit_to_another_paragraph() { |
| 623 | .unwrap(); | 628 | .unwrap(); |
| 624 | let mut server = remote_with(space, |page| replace_text(page, other, 0..0, "Remote ")); | 629 | let mut server = remote_with(space, |page| replace_text(page, other, 0..0, "Remote ")); |
| 625 | assert!( | 630 | assert!( |
| 626 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | 631 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id) |
| 627 | ); | 632 | ); |
| 628 | assert_eq!( | 633 | assert_eq!( |
| 629 | texts(&page_of(&server.durable, space)), | 634 | texts(&page_of(&server.durable, space)), |
| ... | @@ -645,13 +650,13 @@ fn a_join_conflicts_when_the_remote_changed_the_paragraph_it_removes() { | ... | @@ -645,13 +650,13 @@ fn a_join_conflicts_when_the_remote_changed_the_paragraph_it_removes() { |
| 645 | .unwrap(); | 650 | .unwrap(); |
| 646 | let mut server = remote_with(space, |page| replace_text(page, right, 0..0, "Remote ")); | 651 | let mut server = remote_with(space, |page| replace_text(page, right, 0..0, "Remote ")); |
| 647 | assert_eq!( | 652 | assert_eq!( |
| 648 | cache.sync_once(&mut server).unwrap(), | 653 | cache.sync_once(&mut server).unwrap().edit, |
| 649 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 654 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 650 | ); | 655 | ); |
| 651 | assert_eq!(server.publications, 0); | 656 | assert_eq!(server.publications, 0); |
| 652 | review_page(&cache, id, left, |page| join_paragraphs(page, left, right)); | 657 | review_page(&cache, id, left, |page| join_paragraphs(page, left, right)); |
| 653 | assert!(matches!( | 658 | assert!(matches!( |
| 654 | cache.sync_once(&mut server).unwrap(), | 659 | cache.sync_once(&mut server).unwrap().edit, |
| 655 | Some((_, EditStatus::Published { .. })) | 660 | Some((_, EditStatus::Published { .. })) |
| 656 | )); | 661 | )); |
| 657 | assert_eq!( | 662 | assert_eq!( |
| ... | @@ -674,7 +679,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() { | ... | @@ -674,7 +679,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() { |
| 674 | save_page(&cache, left, |page| split_paragraph(page, left, 2)) | 679 | save_page(&cache, left, |page| split_paragraph(page, left, 2)) |
| 675 | .unwrap() | 680 | .unwrap() |
| 676 | .unwrap(); | 681 | .unwrap(); |
| 677 | cache.sync_once(&mut server).unwrap().unwrap(); | 682 | cache.sync_once(&mut server).unwrap().edit.unwrap(); |
| 678 | let tail = body(&page_of(&cache.snapshot().unwrap(), space))[1] | 683 | let tail = body(&page_of(&cache.snapshot().unwrap(), space))[1] |
| 679 | .text() | 684 | .text() |
| 680 | .unwrap() | 685 | .unwrap() |
| ... | @@ -688,7 +693,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() { | ... | @@ -688,7 +693,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() { |
| 688 | let pending = cache.pending().unwrap(); | 693 | let pending = cache.pending().unwrap(); |
| 689 | drop(cache); | 694 | drop(cache); |
| 690 | cache = Replica::open(&path).unwrap(); | 695 | cache = Replica::open(&path).unwrap(); |
| 691 | assert_eq!(cache.snapshot().unwrap(), local); | 696 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 692 | assert_eq!(cache.pending().unwrap(), pending); | 697 | assert_eq!(cache.pending().unwrap(), pending); |
| 693 | let mut remote = page_of(&server.durable, space); | 698 | let mut remote = page_of(&server.durable, space); |
| 694 | replace_text(&mut remote, other, 0..0, "Remote "); | 699 | replace_text(&mut remote, other, 0..0, "Remote "); |
| ... | @@ -698,7 +703,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() { | ... | @@ -698,7 +703,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() { |
| 698 | .to_vec(); | 703 | .to_vec(); |
| 699 | server.durable.clone_from(&server.visible); | 704 | server.durable.clone_from(&server.visible); |
| 700 | assert!( | 705 | assert!( |
| 701 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == dependent) | 706 | matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == dependent) |
| 702 | ); | 707 | ); |
| 703 | assert_eq!( | 708 | assert_eq!( |
| 704 | texts(&page_of(&server.durable, space)), | 709 | texts(&page_of(&server.durable, space)), |
| ... | @@ -751,7 +756,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { | ... | @@ -751,7 +756,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { |
| 751 | assert_eq!(state, EditStatus::Pending); | 756 | assert_eq!(state, EditStatus::Pending); |
| 752 | assert_eq!(cache.pending().unwrap(), pending); | 757 | assert_eq!(cache.pending().unwrap(), pending); |
| 753 | assert!(matches!( | 758 | assert!(matches!( |
| 754 | cache.sync_once(&mut server).unwrap().unwrap().1, | 759 | cache.sync_once(&mut server).unwrap().edit.unwrap().1, |
| 755 | EditStatus::Published { .. } | 760 | EditStatus::Published { .. } |
| 756 | )); | 761 | )); |
| 757 | assert_eq!(server.publications, 2); | 762 | assert_eq!(server.publications, 2); |
| ... | @@ -759,10 +764,10 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { | ... | @@ -759,10 +764,10 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { |
| 759 | Fault::UnknownBefore => { | 764 | Fault::UnknownBefore => { |
| 760 | assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); | 765 | assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); |
| 761 | assert_eq!(cache.pending().unwrap(), pending); | 766 | assert_eq!(cache.pending().unwrap(), pending); |
| 762 | assert_eq!(cache.snapshot().unwrap(), local); | 767 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 763 | for _ in 0..3 { | 768 | for _ in 0..3 { |
| 764 | assert_eq!( | 769 | assert_eq!( |
| 765 | cache.sync_once(&mut server).unwrap(), | 770 | cache.sync_once(&mut server).unwrap().edit, |
| 766 | Some((id, state.clone())) | 771 | Some((id, state.clone())) |
| 767 | ); | 772 | ); |
| 768 | } | 773 | } |
| ... | @@ -772,9 +777,9 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { | ... | @@ -772,9 +777,9 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { |
| 772 | } | 777 | } |
| 773 | Fault::UnknownAfter => { | 778 | Fault::UnknownAfter => { |
| 774 | assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); | 779 | assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); |
| 775 | assert_eq!(cache.snapshot().unwrap(), local); | 780 | assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local)); |
| 776 | assert!(matches!( | 781 | assert!(matches!( |
| 777 | cache.sync_once(&mut server).unwrap().unwrap().1, | 782 | cache.sync_once(&mut server).unwrap().edit.unwrap().1, |
| 778 | EditStatus::Published { .. } | 783 | EditStatus::Published { .. } |
| 779 | )); | 784 | )); |
| 780 | assert_eq!(server.publications, 1); | 785 | assert_eq!(server.publications, 1); |
| ... | @@ -791,7 +796,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { | ... | @@ -791,7 +796,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { |
| 791 | &["ab", "\u{1f980}cd", "Right", "Preserved sibling"] | 796 | &["ab", "\u{1f980}cd", "Right", "Preserved sibling"] |
| 792 | }; | 797 | }; |
| 793 | assert_eq!(texts(&page_of(&server.durable, space)), expected); | 798 | assert_eq!(texts(&page_of(&server.durable, space)), expected); |
| 794 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | 799 | assert_eq!(cache.sync_once(&mut server).unwrap().edit, None); |
| 795 | assert_eq!(cache.snapshot().unwrap(), server.durable); | 800 | assert_eq!(cache.snapshot().unwrap(), server.durable); |
| 796 | } | 801 | } |
| 797 | } | 802 | } |
| ... | @@ -818,17 +823,17 @@ fn a_competing_join_leaves_the_other_client_a_reviewable_split() { | ... | @@ -818,17 +823,17 @@ fn a_competing_join_leaves_the_other_client_a_reviewable_split() { |
| 818 | let pending = second.pending().unwrap(); | 823 | let pending = second.pending().unwrap(); |
| 819 | let mut server = Server::new(BEFORE); | 824 | let mut server = Server::new(BEFORE); |
| 820 | assert!(matches!( | 825 | assert!(matches!( |
| 821 | first.sync_once(&mut server).unwrap().unwrap().1, | 826 | first.sync_once(&mut server).unwrap().edit.unwrap().1, |
| 822 | EditStatus::Published { .. } | 827 | EditStatus::Published { .. } |
| 823 | )); | 828 | )); |
| 824 | assert_eq!( | 829 | assert_eq!( |
| 825 | second.sync_once(&mut server).unwrap(), | 830 | second.sync_once(&mut server).unwrap().edit, |
| 826 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 831 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 827 | ); | 832 | ); |
| 828 | assert_eq!(server.publications, 1); | 833 | assert_eq!(server.publications, 1); |
| 829 | drop(second); | 834 | drop(second); |
| 830 | let second = Replica::open(&path).unwrap(); | 835 | let second = Replica::open(&path).unwrap(); |
| 831 | assert_eq!(second.snapshot().unwrap(), local); | 836 | assert_eq!(pages(&second.snapshot().unwrap()), pages(&local)); |
| 832 | assert_eq!(second.pending().unwrap(), pending); | 837 | assert_eq!(second.pending().unwrap(), pending); |
| 833 | assert_eq!( | 838 | assert_eq!( |
| 834 | second.status(id).unwrap(), | 839 | second.status(id).unwrap(), |
| ... | @@ -838,7 +843,7 @@ fn a_competing_join_leaves_the_other_client_a_reviewable_split() { | ... | @@ -838,7 +843,7 @@ fn a_competing_join_leaves_the_other_client_a_reviewable_split() { |
| 838 | split_paragraph(page, left, 2); | 843 | split_paragraph(page, left, 2); |
| 839 | }); | 844 | }); |
| 840 | assert!(matches!( | 845 | assert!(matches!( |
| 841 | second.sync_once(&mut server).unwrap().unwrap().1, | 846 | second.sync_once(&mut server).unwrap().edit.unwrap().1, |
| 842 | EditStatus::Published { .. } | 847 | EditStatus::Published { .. } |
| 843 | )); | 848 | )); |
| 844 | assert_eq!( | 849 | assert_eq!( |
crates/notebook/tests/sync_worker.rs+72-54| ... | @@ -111,7 +111,12 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() { | ... | @@ -111,7 +111,12 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() { |
| 111 | }, | 111 | }, |
| 112 | move |result| { | 112 | move |result| { |
| 113 | observed_tx | 113 | observed_tx |
| 114 | .send(result.as_ref().cloned().map_err(|error| error.to_string())) | 114 | .send( |
| 115 | result | ||
| 116 | .as_ref() | ||
| 117 | .map(|synced| synced.edit.clone()) | ||
| 118 | .map_err(|error| error.to_string()), | ||
| 119 | ) | ||
| 115 | .unwrap(); | 120 | .unwrap(); |
| 116 | }, | 121 | }, |
| 117 | ) | 122 | ) |
| ... | @@ -166,7 +171,9 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { | ... | @@ -166,7 +171,9 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { |
| 166 | Duration::from_secs(3600), | 171 | Duration::from_secs(3600), |
| 167 | move || Ok(shared.clone()), | 172 | move || Ok(shared.clone()), |
| 168 | move |result| { | 173 | move |result| { |
| 169 | observed_tx.send(result.as_ref().unwrap().clone()).unwrap(); | 174 | observed_tx |
| 175 | .send(result.as_ref().unwrap().edit.clone()) | ||
| 176 | .unwrap(); | ||
| 170 | if first { | 177 | if first { |
| 171 | first = false; | 178 | first = false; |
| 172 | resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); | 179 | resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); |
| ... | @@ -185,16 +192,11 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { | ... | @@ -185,16 +192,11 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { |
| 185 | let cache = &cache; | 192 | let cache = &cache; |
| 186 | scope.spawn(move || { | 193 | scope.spawn(move || { |
| 187 | let marker = format!("[{writer}] "); | 194 | let marker = format!("[{writer}] "); |
| 188 | loop { | 195 | model_ops::save(cache, oid, |page| { |
| 189 | match model_ops::save(cache, oid, |page| { | 196 | model_ops::replace_text(page, oid, 0..0, &marker) |
| 190 | model_ops::replace_text(page, oid, 0..0, &marker) | 197 | }) |
| 191 | }) { | 198 | .unwrap() |
| 192 | Ok(Some(id)) => break id, | 199 | .unwrap() |
| 193 | Err(Error::Io(error)) | ||
| 194 | if error.kind() == io::ErrorKind::ResourceBusy => {} | ||
| 195 | other => panic!("Unexpected local outcome: {other:?}"), | ||
| 196 | } | ||
| 197 | } | ||
| 198 | }) | 200 | }) |
| 199 | }) | 201 | }) |
| 200 | .collect::<Vec<_>>() | 202 | .collect::<Vec<_>>() |
| ... | @@ -203,11 +205,9 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { | ... | @@ -203,11 +205,9 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { |
| 203 | .collect::<std::collections::BTreeSet<_>>() | 205 | .collect::<std::collections::BTreeSet<_>>() |
| 204 | }); | 206 | }); |
| 205 | resume_tx.send(()).unwrap(); | 207 | resume_tx.send(()).unwrap(); |
| 206 | let mut published = std::collections::BTreeSet::new(); | ||
| 207 | while !cache.pending().unwrap().is_empty() { | 208 | while !cache.pending().unwrap().is_empty() { |
| 208 | if let Some((id, status)) = observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() { | 209 | if let Some((_, status)) = observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() { |
| 209 | assert!(matches!(status, EditStatus::Published { .. }), "{status:?}"); | 210 | assert!(matches!(status, EditStatus::Published { .. }), "{status:?}"); |
| 210 | published.insert(id); | ||
| 211 | } | 211 | } |
| 212 | } | 212 | } |
| 213 | assert!( | 213 | assert!( |
| ... | @@ -215,7 +215,12 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { | ... | @@ -215,7 +215,12 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { |
| 215 | "Edits waited for the hourly poll" | 215 | "Edits waited for the hourly poll" |
| 216 | ); | 216 | ); |
| 217 | worker.stop().unwrap(); | 217 | worker.stop().unwrap(); |
| 218 | assert_eq!(published, queued); | 218 | for id in &queued { |
| 219 | assert!(matches!( | ||
| 220 | cache.status(*id).unwrap(), | ||
| 221 | Some(EditStatus::Published { .. }) | ||
| 222 | )); | ||
| 223 | } | ||
| 219 | let content = text(&cache.snapshot().unwrap()).2; | 224 | let content = text(&cache.snapshot().unwrap()).2; |
| 220 | for writer in 0..12 { | 225 | for writer in 0..12 { |
| 221 | assert_eq!(content.matches(&format!("[{writer}] ")).count(), 1); | 226 | assert_eq!(content.matches(&format!("[{writer}] ")).count(), 1); |
| ... | @@ -223,7 +228,8 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { | ... | @@ -223,7 +228,8 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { |
| 223 | assert!(content.ends_with("abc")); | 228 | assert!(content.ends_with("abc")); |
| 224 | let server = server.lock().unwrap(); | 229 | let server = server.lock().unwrap(); |
| 225 | assert_eq!(text(&server.durable).2, content); | 230 | assert_eq!(text(&server.durable).2, content); |
| 226 | assert_eq!(server.publications, queued.len()); | 231 | // Edits queued while the worker was busy publish together. |
| 232 | assert_eq!(server.publications, 1); | ||
| 227 | assert_eq!(cache.snapshot().unwrap(), server.durable); | 233 | assert_eq!(cache.snapshot().unwrap(), server.durable); |
| 228 | } | 234 | } |
| 229 | 235 | ||
| ... | @@ -308,7 +314,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove | ... | @@ -308,7 +314,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove |
| 308 | Duration::from_secs(3600), | 314 | Duration::from_secs(3600), |
| 309 | move || Ok(shared.clone()), | 315 | move || Ok(shared.clone()), |
| 310 | move |result| { | 316 | move |result| { |
| 311 | tx.send(result.as_ref().unwrap().clone()).unwrap(); | 317 | tx.send(result.as_ref().unwrap().edit.clone()).unwrap(); |
| 312 | }, | 318 | }, |
| 313 | ) | 319 | ) |
| 314 | .unwrap(); | 320 | .unwrap(); |
| ... | @@ -332,7 +338,7 @@ fn cache_failures_stop_retries_and_return_the_error_without_remote_publication() | ... | @@ -332,7 +338,7 @@ fn cache_failures_stop_retries_and_return_the_error_without_remote_publication() |
| 332 | let id = save(&cache, oid, 0..0, "L ").unwrap(); | 338 | let id = save(&cache, oid, 0..0, "L ").unwrap(); |
| 333 | drop(cache); | 339 | drop(cache); |
| 334 | let connection = rusqlite::Connection::open(&path).unwrap(); | 340 | let connection = rusqlite::Connection::open(&path).unwrap(); |
| 335 | connection.execute_batch("CREATE TRIGGER fail_attempt BEFORE INSERT ON attempt BEGIN SELECT RAISE(ABORT, 'test cache write failure'); END;").unwrap(); | 341 | connection.execute_batch("CREATE TRIGGER fail_attempt BEFORE UPDATE OF attempted ON batches BEGIN SELECT RAISE(ABORT, 'test cache write failure'); END;").unwrap(); |
| 336 | drop(connection); | 342 | drop(connection); |
| 337 | let cache = Arc::new(Replica::open(&path).unwrap()); | 343 | let cache = Arc::new(Replica::open(&path).unwrap()); |
| 338 | let server = Arc::new(Mutex::new(Server::new(&source))); | 344 | let server = Arc::new(Mutex::new(Server::new(&source))); |
| ... | @@ -356,14 +362,14 @@ fn cache_failures_stop_retries_and_return_the_error_without_remote_publication() | ... | @@ -356,14 +362,14 @@ fn cache_failures_stop_retries_and_return_the_error_without_remote_publication() |
| 356 | } | 362 | } |
| 357 | 363 | ||
| 358 | #[test] | 364 | #[test] |
| 359 | fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() { | 365 | fn polling_reports_a_blocked_queue_once_per_remote_change_without_replay() { |
| 360 | for uncertain in [false, true] { | 366 | for uncertain in [false, true] { |
| 361 | let dir = tempfile::tempdir().unwrap(); | 367 | let dir = tempfile::tempdir().unwrap(); |
| 362 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | 368 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); |
| 363 | let (sid, oid, _) = text(&source); | 369 | let (sid, oid, _) = text(&source); |
| 364 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | 370 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); |
| 365 | let id = save(&cache, oid, 1..2, "L").unwrap(); | 371 | let id = save(&cache, oid, 1..2, "L").unwrap(); |
| 366 | let local = cache.snapshot().unwrap(); | 372 | let local = text(&cache.snapshot().unwrap()).2; |
| 367 | let mut server = if uncertain { | 373 | let mut server = if uncertain { |
| 368 | Server::new(&source) | 374 | Server::new(&source) |
| 369 | } else { | 375 | } else { |
| ... | @@ -380,38 +386,48 @@ fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() { | ... | @@ -380,38 +386,48 @@ fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() { |
| 380 | Duration::from_millis(10), | 386 | Duration::from_millis(10), |
| 381 | move || Ok(shared.clone()), | 387 | move || Ok(shared.clone()), |
| 382 | move |result| { | 388 | move |result| { |
| 383 | tx.send(result.as_ref().cloned().map_err(|_| ())).unwrap(); | 389 | tx.send( |
| 390 | result | ||
| 391 | .as_ref() | ||
| 392 | .map(|synced| synced.edit.clone()) | ||
| 393 | .map_err(|_| ()), | ||
| 394 | ) | ||
| 395 | .unwrap(); | ||
| 384 | }, | 396 | }, |
| 385 | ) | 397 | ) |
| 386 | .unwrap(); | 398 | .unwrap(); |
| 387 | if uncertain { | 399 | if uncertain { |
| 388 | assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); | 400 | assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); |
| 389 | } | 401 | } |
| 390 | let mut previous = None; | 402 | let expected = |status: &EditStatus| { |
| 391 | let started = Instant::now(); | 403 | if uncertain { |
| 392 | for _ in 0..5 { | 404 | matches!(status, EditStatus::AwaitingConfirmation { .. }) |
| 405 | } else { | ||
| 406 | *status == EditStatus::Conflict(ConflictKind::ContentChanged) | ||
| 407 | } | ||
| 408 | }; | ||
| 409 | for round in 0..3 { | ||
| 393 | let (actual, status) = rx | 410 | let (actual, status) = rx |
| 394 | .recv_timeout(Duration::from_secs(5)) | 411 | .recv_timeout(Duration::from_secs(5)) |
| 395 | .unwrap() | 412 | .unwrap() |
| 396 | .unwrap() | 413 | .unwrap() |
| 397 | .unwrap(); | 414 | .unwrap(); |
| 398 | assert_eq!(actual, id); | 415 | assert_eq!(actual, id); |
| 399 | if uncertain { | 416 | assert!(expected(&status), "{status:?}"); |
| 400 | assert!(matches!(status, EditStatus::AwaitingConfirmation { .. })); | 417 | // An unchanged remote is not read or reported again. |
| 401 | } else { | 418 | assert!(rx.recv_timeout(Duration::from_millis(100)).is_err()); |
| 402 | assert_eq!(status, EditStatus::Conflict(ConflictKind::ContentChanged)); | 419 | // Another writer's change elsewhere in the text is read and decided again. |
| 403 | } | 420 | let mut server = server.lock().unwrap(); |
| 404 | if let Some(previous) = previous { | 421 | let changed = |
| 405 | assert_eq!(previous, status); | 422 | onestore::replace_text(&server.visible, sid, oid, 0..0, &round.to_string()) |
| 406 | } | 423 | .unwrap(); |
| 407 | previous = Some(status.clone()); | 424 | server.visible = changed.clone(); |
| 425 | server.durable = changed; | ||
| 426 | drop(server); | ||
| 427 | worker.wake(); | ||
| 408 | } | 428 | } |
| 409 | assert!( | ||
| 410 | started.elapsed() >= Duration::from_millis(30), | ||
| 411 | "Worker spun instead of waiting between retries" | ||
| 412 | ); | ||
| 413 | worker.stop().unwrap(); | 429 | worker.stop().unwrap(); |
| 414 | assert_eq!(cache.snapshot().unwrap(), local); | 430 | assert_eq!(text(&cache.snapshot().unwrap()).2, local); |
| 415 | assert_eq!(cache.pending().unwrap().len(), 1); | 431 | assert_eq!(cache.pending().unwrap().len(), 1); |
| 416 | let server = server.lock().unwrap(); | 432 | let server = server.lock().unwrap(); |
| 417 | assert_eq!(server.publications, usize::from(uncertain)); | 433 | assert_eq!(server.publications, usize::from(uncertain)); |
| ... | @@ -511,7 +527,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() { | ... | @@ -511,7 +527,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() { |
| 511 | Duration::from_secs(3600), | 527 | Duration::from_secs(3600), |
| 512 | move || Ok(shared.clone()), | 528 | move || Ok(shared.clone()), |
| 513 | move |result| { | 529 | move |result| { |
| 514 | tx.send(result.as_ref().unwrap().clone()).unwrap(); | 530 | tx.send(result.as_ref().unwrap().edit.clone()).unwrap(); |
| 515 | }, | 531 | }, |
| 516 | ) | 532 | ) |
| 517 | .unwrap(); | 533 | .unwrap(); |
| ... | @@ -521,7 +537,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() { | ... | @@ -521,7 +537,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() { |
| 521 | } | 537 | } |
| 522 | 538 | ||
| 523 | #[test] | 539 | #[test] |
| 524 | fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() { | 540 | fn a_resolved_conflict_wakes_the_worker_and_publishes_the_original_edit_once() { |
| 525 | let dir = tempfile::tempdir().unwrap(); | 541 | let dir = tempfile::tempdir().unwrap(); |
| 526 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | 542 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); |
| 527 | let (sid, oid, _) = text(&source); | 543 | let (sid, oid, _) = text(&source); |
| ... | @@ -538,7 +554,7 @@ fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() { | ... | @@ -538,7 +554,7 @@ fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() { |
| 538 | Duration::from_secs(3600), | 554 | Duration::from_secs(3600), |
| 539 | move || Ok(shared.clone()), | 555 | move || Ok(shared.clone()), |
| 540 | move |result| { | 556 | move |result| { |
| 541 | tx.send(result.as_ref().unwrap().clone()).unwrap(); | 557 | tx.send(result.as_ref().unwrap().edit.clone()).unwrap(); |
| 542 | if first { | 558 | if first { |
| 543 | first = false; | 559 | first = false; |
| 544 | resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); | 560 | resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); |
| ... | @@ -550,10 +566,7 @@ fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() { | ... | @@ -550,10 +566,7 @@ fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() { |
| 550 | rx.recv_timeout(Duration::from_secs(5)).unwrap(), | 566 | rx.recv_timeout(Duration::from_secs(5)).unwrap(), |
| 551 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 567 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 552 | ); | 568 | ); |
| 553 | model_ops::review(&cache, id, oid, |page| { | 569 | cache.resolve(id, notebook::Resolution::Mine).unwrap(); |
| 554 | model_ops::replace_text(page, oid, 1..2, "L") | ||
| 555 | }) | ||
| 556 | .unwrap(); | ||
| 557 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | 570 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); |
| 558 | resume_tx.send(()).unwrap(); | 571 | resume_tx.send(()).unwrap(); |
| 559 | assert!( | 572 | assert!( |
| ... | @@ -618,8 +631,13 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() { | ... | @@ -618,8 +631,13 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() { |
| 618 | Duration::from_millis(1), | 631 | Duration::from_millis(1), |
| 619 | move || Ok(remote.take().expect("Contention caused a reconnect")), | 632 | move || Ok(remote.take().expect("Contention caused a reconnect")), |
| 620 | move |result| { | 633 | move |result| { |
| 621 | tx.send(result.as_ref().cloned().map_err(|error| error.to_string())) | 634 | tx.send( |
| 622 | .unwrap(); | 635 | result |
| 636 | .as_ref() | ||
| 637 | .map(|synced| synced.edit.clone()) | ||
| 638 | .map_err(|error| error.to_string()), | ||
| 639 | ) | ||
| 640 | .unwrap(); | ||
| 623 | }, | 641 | }, |
| 624 | ) | 642 | ) |
| 625 | .unwrap(); | 643 | .unwrap(); |
| ... | @@ -675,14 +693,14 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() { | ... | @@ -675,14 +693,14 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() { |
| 675 | failed = true; | 693 | failed = true; |
| 676 | let page = | 694 | let page = |
| 677 | onestore::PageCreation::new(None, Some("Queued"), "Fixture").unwrap(); | 695 | onestore::PageCreation::new(None, Some("Queued"), "Fixture").unwrap(); |
| 678 | let second = observed | 696 | let second = section_op(&observed, onestore::op::SectionOp::Create(page)); |
| 679 | .create_page(&observed.snapshot().unwrap(), &page) | ||
| 680 | .unwrap() | ||
| 681 | .unwrap(); | ||
| 682 | tx.send((second, None)).unwrap(); | 697 | tx.send((second, None)).unwrap(); |
| 683 | } | 698 | } |
| 684 | Ok(Some((id, status))) => tx.send((*id, Some(status.clone()))).unwrap(), | 699 | Ok(notebook::Synced { |
| 685 | Ok(None) => {} | 700 | edit: Some((id, status)), |
| 701 | .. | ||
| 702 | }) => tx.send((*id, Some(status.clone()))).unwrap(), | ||
| 703 | Ok(_) => {} | ||
| 686 | other => panic!("Unexpected worker result: {other:?}"), | 704 | other => panic!("Unexpected worker result: {other:?}"), |
| 687 | }, | 705 | }, |
| 688 | ) | 706 | ) |
crates/onestore/src/commit.rs+13| ... | @@ -644,6 +644,19 @@ impl Transaction { | ... | @@ -644,6 +644,19 @@ impl Transaction { |
| 644 | } | 644 | } |
| 645 | } | 645 | } |
| 646 | 646 | ||
| 647 | /// The image this transaction applies to. | ||
| 648 | pub fn base(&self) -> &Stamp { | ||
| 649 | &self.base | ||
| 650 | } | ||
| 651 | |||
| 652 | /// The bytes a commit writes, by offset, in the order `apply` writes them; the header, | ||
| 653 | /// last, covers bytes 0..1024. | ||
| 654 | pub fn writes(&self) -> impl Iterator<Item = (u64, &[u8])> { | ||
| 655 | std::iter::once((self.base.length, self.append.as_slice())) | ||
| 656 | .chain(self.patches.iter().map(|(offset, bytes)| (*offset, bytes.as_slice()))) | ||
| 657 | .chain(std::iter::once((0, self.header.as_slice()))) | ||
| 658 | } | ||
| 659 | |||
| 647 | /// Writes this transaction into its base image, as a successful commit leaves the file. | 660 | /// Writes this transaction into its base image, as a successful commit leaves the file. |
| 648 | pub fn apply(&self, image: &mut Vec<u8>) -> Result<(), crate::Error> { | 661 | pub fn apply(&self, image: &mut Vec<u8>) -> Result<(), crate::Error> { |
| 649 | if Stamp::of(image)? != self.base { | 662 | if Stamp::of(image)? != self.base { |
crates/onestore/src/op/apply.rs+4-3| ... | @@ -15,7 +15,7 @@ use crate::{ | ... | @@ -15,7 +15,7 @@ use crate::{ |
| 15 | active::{ActivePage, Changes}, | 15 | active::{ActivePage, Changes}, |
| 16 | document::Kind, | 16 | document::Kind, |
| 17 | page::{ | 17 | page::{ |
| 18 | Attachment, Image, Ink, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, | 18 | Attachment, Image, Ink, PageObject, PageParagraph, Paragraph, ParagraphContent, |
| 19 | TableCell, TableColumn, | 19 | TableCell, TableColumn, |
| 20 | }, | 20 | }, |
| 21 | }; | 21 | }; |
| ... | @@ -57,12 +57,13 @@ impl<'a> Section<'a> { | ... | @@ -57,12 +57,13 @@ impl<'a> Section<'a> { |
| 57 | 57 | ||
| 58 | /// Lowers `after` against the page `space` holds and applies the result as one edit; | 58 | /// Lowers `after` against the page `space` holds and applies the result as one edit; |
| 59 | /// returns the ops, which reach `after` where the writers can store it. | 59 | /// returns the ops, which reach `after` where the writers can store it. |
| 60 | pub fn apply_page( | 60 | #[cfg(test)] |
| 61 | pub(crate) fn apply_page( | ||
| 61 | &mut self, | 62 | &mut self, |
| 62 | author: &str, | 63 | author: &str, |
| 63 | at: u64, | 64 | at: u64, |
| 64 | space: ExGuid, | 65 | space: ExGuid, |
| 65 | after: &Page, | 66 | after: &crate::page::Page, |
| 66 | ) -> Result<Vec<PageOp>, OpError> { | 67 | ) -> Result<Vec<PageOp>, OpError> { |
| 67 | let before = self.page(space).map_err(|error| self.classify(Failure::Rejected(error)))?; | 68 | let before = self.page(space).map_err(|error| self.classify(Failure::Rejected(error)))?; |
| 68 | let ops = super::lower_page(&before, after) | 69 | let ops = super::lower_page(&before, after) |
crates/onestore/src/op/lower.rs+2-1| ... | @@ -20,7 +20,8 @@ fn invalid(message: &'static str) -> Error { | ... | @@ -20,7 +20,8 @@ fn invalid(message: &'static str) -> Error { |
| 20 | Error { offset: 0, message } | 20 | Error { offset: 0, message } |
| 21 | } | 21 | } |
| 22 | 22 | ||
| 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. | ||
| 24 | pub fn lower_page(before: &Page, after: &Page) -> Result<Vec<PageOp>, Error> { | 25 | pub fn lower_page(before: &Page, after: &Page) -> Result<Vec<PageOp>, Error> { |
| 25 | let old = View::new(before, false)?; | 26 | let old = View::new(before, false)?; |
| 26 | let new = View::new(after, false)?; | 27 | let new = View::new(after, false)?; |
crates/onestore/src/op/mod.rs+5| ... | @@ -28,6 +28,7 @@ pub(crate) type Values = Vec<(u32, Vec<u8>)>; | ... | @@ -28,6 +28,7 @@ pub(crate) type Values = Vec<(u32, Vec<u8>)>; |
| 28 | 28 | ||
| 29 | /// One user action, applied whole or not at all. | 29 | /// One user action, applied whole or not at all. |
| 30 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | 30 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 31 | #[serde(deny_unknown_fields)] | ||
| 31 | pub struct Edit { | 32 | pub struct Edit { |
| 32 | /// FILETIME when the action happened; the modification time of what it changes. | 33 | /// FILETIME when the action happened; the modification time of what it changes. |
| 33 | pub at: u64, | 34 | pub at: u64, |
| ... | @@ -35,6 +36,7 @@ pub struct Edit { | ... | @@ -35,6 +36,7 @@ pub struct Edit { |
| 35 | } | 36 | } |
| 36 | 37 | ||
| 37 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | 38 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 39 | #[serde(deny_unknown_fields)] | ||
| 38 | pub enum Op { | 40 | pub enum Op { |
| 39 | Page { space: ExGuid, op: PageOp }, | 41 | Page { space: ExGuid, op: PageOp }, |
| 40 | Section(SectionOp), | 42 | Section(SectionOp), |
| ... | @@ -43,6 +45,7 @@ pub enum Op { | ... | @@ -43,6 +45,7 @@ pub enum Op { |
| 43 | /// A change to the page an object space holds. Targets are stored identities; paragraph | 45 | /// A change to the page an object space holds. Targets are stored identities; paragraph |
| 44 | /// properties name the paragraph, text edits its text object. | 46 | /// properties name the paragraph, text edits its text object. |
| 45 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | 47 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 48 | #[serde(deny_unknown_fields)] | ||
| 46 | pub enum PageOp { | 49 | pub enum PageOp { |
| 47 | /// Replaces a range; inserted text takes the format of the run it lands in, the | 50 | /// Replaces a range; inserted text takes the format of the run it lands in, the |
| 48 | /// following run at a boundary except at the end. | 51 | /// following run at a boundary except at the end. |
| ... | @@ -159,6 +162,7 @@ pub enum PageOp { | ... | @@ -159,6 +162,7 @@ pub enum PageOp { |
| 159 | } | 162 | } |
| 160 | 163 | ||
| 161 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | 164 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 165 | #[serde(deny_unknown_fields)] | ||
| 162 | pub enum TableEdit { | 166 | pub enum TableEdit { |
| 163 | /// New rows before a row, or last, each with one cell per column. | 167 | /// New rows before a row, or last, each with one cell per column. |
| 164 | Rows { | 168 | Rows { |
| ... | @@ -185,6 +189,7 @@ pub enum TableEdit { | ... | @@ -185,6 +189,7 @@ pub enum TableEdit { |
| 185 | } | 189 | } |
| 186 | 190 | ||
| 187 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | 191 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 192 | #[serde(deny_unknown_fields)] | ||
| 188 | pub enum SectionOp { | 193 | pub enum SectionOp { |
| 189 | Create(PageCreation), | 194 | Create(PageCreation), |
| 190 | /// A page created with `creation` holding `page`'s content under the identities it | 195 | /// A page created with `creation` holding `page`'s content under the identities it |
crates/onestore/src/section.rs+8-1| ... | @@ -202,6 +202,13 @@ impl<'a> Section<'a> { | ... | @@ -202,6 +202,13 @@ impl<'a> Section<'a> { |
| 202 | image | 202 | image |
| 203 | } | 203 | } |
| 204 | 204 | ||
| 205 | /// Each object space with the active revision the sealed image stores for it. | ||
| 206 | pub fn revisions(&self) -> impl Iterator<Item = (ExGuid, ExGuid)> + '_ { | ||
| 207 | self.spaces | ||
| 208 | .iter() | ||
| 209 | .filter_map(|(space, stored)| Some((*space, stored.rid?))) | ||
| 210 | } | ||
| 211 | |||
| 205 | /// The page an object space holds, with the changes applied since the last seal. | 212 | /// The page an object space holds, with the changes applied since the last seal. |
| 206 | pub fn page(&self, space: ExGuid) -> Result<Page> { | 213 | pub fn page(&self, space: ExGuid) -> Result<Page> { |
| 207 | let stored = self.spaces.get(&space).ok_or(Error { | 214 | let stored = self.spaces.get(&space).ok_or(Error { |
| ... | @@ -429,7 +436,7 @@ impl<'a> Section<'a> { | ... | @@ -429,7 +436,7 @@ impl<'a> Section<'a> { |
| 429 | } | 436 | } |
| 430 | 437 | ||
| 431 | /// The root object space, which lists the pages. | 438 | /// The root object space, which lists the pages. |
| 432 | pub(crate) fn root(&self) -> ExGuid { | 439 | pub fn root(&self) -> ExGuid { |
| 433 | self.root | 440 | self.root |
| 434 | } | 441 | } |
| 435 | 442 |
crates/onestore/src/store.rs+1-1| ... | @@ -86,7 +86,7 @@ pub struct Header { | ... | @@ -86,7 +86,7 @@ pub struct Header { |
| 86 | } | 86 | } |
| 87 | 87 | ||
| 88 | impl Header { | 88 | impl Header { |
| 89 | pub(crate) fn parse(data: &[u8]) -> Result<Self> { | 89 | pub fn parse(data: &[u8]) -> Result<Self> { |
| 90 | let mut c = Cursor { | 90 | let mut c = Cursor { |
| 91 | bytes: data, | 91 | bytes: data, |
| 92 | offset: 0, | 92 | offset: 0, |
tools/cache_images.py+34-14| ... | @@ -1,17 +1,37 @@ | ... | @@ -1,17 +1,37 @@ |
| 1 | """The cache's working image, stored as the byte ranges where it differs from the base.""" | 1 | """Reading a notebook cache (schema 15) from outside: its section images, stored as 16 KiB |
| 2 | chunk rows, and the state of its write-ahead log.""" | ||
| 2 | import struct | 3 | import struct |
| 3 | 4 | ||
| 4 | 5 | ||
| 5 | def working(connection): | 6 | def image(connection, table='base'): |
| 6 | base, patch = connection.execute('SELECT base, working FROM replica WHERE id=1').fetchone() | 7 | """The `base` image the queued edits apply to, or the observed `remote` image (None |
| 7 | if not patch: return base | 8 | unless a conflict or uncertain attempt holds one).""" |
| 8 | length, = struct.unpack_from('<Q', patch) | 9 | chunks = connection.execute(f'SELECT chunk, bytes FROM {table} ORDER BY chunk').fetchall() |
| 9 | image = bytearray(base[:length].ljust(length, b'\0')) | 10 | assert [chunk for chunk, _ in chunks] == list(range(len(chunks))), 'Damaged cached image' |
| 10 | at = 8 | 11 | return b''.join(bytes(data) for _, data in chunks) or None |
| 11 | while at < len(patch): | 12 | |
| 12 | offset, size = struct.unpack_from('<QQ', patch, at) | 13 | |
| 13 | at += 16 | 14 | def wal_commits(path): |
| 14 | assert at + size <= len(patch) and offset + size <= length, 'Damaged working image' | 15 | """Commits in `<cache>-wal` since its last reset: frames whose header records the |
| 15 | image[offset:offset + size] = patch[at:at + size] | 16 | database size after them, among the frames carrying the log's current salts. Frames |
| 16 | at += size | 17 | after the last commit belong to a transaction that has not committed.""" |
| 17 | return bytes(image) | 18 | try: |
| 19 | data = path.with_name(path.name + '-wal').read_bytes() | ||
| 20 | except FileNotFoundError: | ||
| 21 | return 0, 0 | ||
| 22 | if len(data) < 32: | ||
| 23 | return 0, 0 | ||
| 24 | magic, _, page_size, _, salt1, salt2 = struct.unpack_from('>IIIIII', data) | ||
| 25 | assert magic in (0x377f0682, 0x377f0683), 'Not a SQLite write-ahead log' | ||
| 26 | commits = pending = 0 | ||
| 27 | at = 32 | ||
| 28 | while at + 24 + page_size <= len(data): | ||
| 29 | _, committed, frame1, frame2 = struct.unpack_from('>IIII', data, at) | ||
| 30 | if (frame1, frame2) != (salt1, salt2): | ||
| 31 | break | ||
| 32 | if committed: | ||
| 33 | commits, pending = commits + 1, 0 | ||
| 34 | else: | ||
| 35 | pending += 1 | ||
| 36 | at += 24 + page_size | ||
| 37 | return commits, pending |
tools/offline_cache_crash.py+16-23| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | """Interrupt owned offline-cache writers and verify every retained intent and image.""" | 2 | """Interrupt owned offline-cache writers and verify every retained intent and image.""" |
| 3 | import argparse | 3 | import argparse |
| 4 | import cache_images | ||
| 4 | import hashlib | 5 | import hashlib |
| 5 | import json | 6 | import json |
| 6 | import os | 7 | import os |
| ... | @@ -31,7 +32,7 @@ def run(binary, output): | ... | @@ -31,7 +32,7 @@ def run(binary, output): |
| 31 | retained = [] | 32 | retained = [] |
| 32 | retained_ids = [] | 33 | retained_ids = [] |
| 33 | results = [] | 34 | results = [] |
| 34 | delays = [None, "ack", "unack", "journal", "database-write", 0, .001, .01, .02, .04, .08, .16, .32, .64, 1.28, 2.56, None, "ack"] | 35 | delays = [None, "ack", "unack", "wal", 0, .001, .01, .02, .04, .08, .16, .32, .64, 1.28, 2.56, None, "ack"] |
| 35 | for operation, delay in enumerate(delays, 1): | 36 | for operation, delay in enumerate(delays, 1): |
| 36 | with (output / f"owner-{operation}.stderr").open("w") as stderr: | 37 | with (output / f"owner-{operation}.stderr").open("w") as stderr: |
| 37 | owner = subprocess.Popen([binary, "edit", cache], stdin=subprocess.PIPE, | 38 | owner = subprocess.Popen([binary, "edit", cache], stdin=subprocess.PIPE, |
| ... | @@ -48,34 +49,28 @@ def run(binary, output): | ... | @@ -48,34 +49,28 @@ def run(binary, output): |
| 48 | contender = subprocess.run([binary, "read", cache], capture_output=True, text=True, timeout=15) | 49 | contender = subprocess.run([binary, "read", cache], capture_output=True, text=True, timeout=15) |
| 49 | (output / f"contender-{operation}.stderr").write_text(contender.stderr) | 50 | (output / f"contender-{operation}.stderr").write_text(contender.stderr) |
| 50 | assert contender.returncode != 0 and "DatabaseBusy" in contender.stderr, contender | 51 | assert contender.returncode != 0 and "DatabaseBusy" in contender.stderr, contender |
| 51 | before_write = cache.stat().st_mtime_ns | ||
| 52 | owner.stdin.write(f"{operation} {'unack' if delay == 'unack' else 'ack'}\n") | 52 | owner.stdin.write(f"{operation} {'unack' if delay == 'unack' else 'ack'}\n") |
| 53 | owner.stdin.flush() | 53 | owner.stdin.flush() |
| 54 | expect(f"editing {operation}") | 54 | expect(f"editing {operation}") |
| 55 | acknowledgement = None | 55 | acknowledgement = None |
| 56 | journal_observation = None | 56 | log_observation = None |
| 57 | if delay == "ack": | 57 | if delay == "ack": |
| 58 | acknowledgement = expect(f"ack {operation}") | 58 | acknowledgement = expect(f"ack {operation}") |
| 59 | elif delay == "unack": | 59 | elif delay == "unack": |
| 60 | expect(f"durable {operation}") | 60 | expect(f"durable {operation}") |
| 61 | elif delay in ("journal", "database-write"): | 61 | elif delay == "wal": |
| 62 | # Cut the edit's transaction once its frames are in the log and its | ||
| 63 | # commit frame is not. | ||
| 62 | deadline = time.monotonic() + 10 | 64 | deadline = time.monotonic() + 10 |
| 63 | journal = cache.with_name(cache.name + "-journal") | 65 | committed, _ = cache_images.wal_commits(cache) |
| 64 | while time.monotonic() < deadline: | 66 | while time.monotonic() < deadline: |
| 65 | try: | 67 | commits, pending = cache_images.wal_commits(cache) |
| 66 | with journal.open("rb") as active: | 68 | if commits == committed and pending: |
| 67 | header = active.read(28) | 69 | log_observation = {"wal_commits": commits, "uncommitted_frames": pending} |
| 68 | size = journal.stat().st_size | 70 | break |
| 69 | database_changed = cache.stat().st_mtime_ns != before_write | 71 | if commits != committed: |
| 70 | if (header[:8] == bytes.fromhex("d9d505f920a163d7") and | 72 | break |
| 71 | (delay == "journal" or database_changed)): | 73 | assert log_observation, "No uncommitted log frames observed" |
| 72 | journal_observation = {"header": header.hex(), "bytes": size, | ||
| 73 | "database_changed": database_changed} | ||
| 74 | break | ||
| 75 | except FileNotFoundError: | ||
| 76 | pass | ||
| 77 | time.sleep(.0001) | ||
| 78 | assert journal_observation, f"No active {delay} phase observed" | ||
| 79 | elif delay is not None: | 74 | elif delay is not None: |
| 80 | time.sleep(delay) | 75 | time.sleep(delay) |
| 81 | owner.kill() | 76 | owner.kill() |
| ... | @@ -88,9 +83,7 @@ def run(binary, output): | ... | @@ -88,9 +83,7 @@ def run(binary, output): |
| 88 | actual = json.loads(read.stdout) | 83 | actual = json.loads(read.stdout) |
| 89 | operations = actual["operations"] | 84 | operations = actual["operations"] |
| 90 | ids = actual["ids"] | 85 | ids = actual["ids"] |
| 91 | # A save replaces the newest pending save of its page under the same intent. | 86 | assert operations in (retained, retained + [operation]), (retained, operation, actual) |
| 92 | coalesced = retained[:-1] + [operation] if ids == retained_ids else None | ||
| 93 | assert operations in (retained, retained + [operation], coalesced), (retained, operation, actual) | ||
| 94 | assert ids[:len(retained_ids)] == retained_ids, (retained_ids, actual) | 87 | assert ids[:len(retained_ids)] == retained_ids, (retained_ids, actual) |
| 95 | if acknowledgement: | 88 | if acknowledgement: |
| 96 | assert operations[-1] == operation | 89 | assert operations[-1] == operation |
| ... | @@ -105,7 +98,7 @@ def run(binary, output): | ... | @@ -105,7 +98,7 @@ def run(binary, output): |
| 105 | "retained_operations": operations, "ids": ids, | 98 | "retained_operations": operations, "ids": ids, |
| 106 | "section_bytes": actual["section_bytes"], | 99 | "section_bytes": actual["section_bytes"], |
| 107 | "complete_payloads": True, "exclusive_owner": True, | 100 | "complete_payloads": True, "exclusive_owner": True, |
| 108 | "journal_observation": journal_observation}) | 101 | "log_observation": log_observation}) |
| 109 | retained, retained_ids = operations, ids | 102 | retained, retained_ids = operations, ids |
| 110 | (output / "results.json").write_text(json.dumps(results, indent=2)) | 103 | (output / "results.json").write_text(json.dumps(results, indent=2)) |
| 111 | print(json.dumps(results[-1]), flush=True) | 104 | print(json.dumps(results[-1]), flush=True) |
tools/offline_publication_crash.py+17-19| ... | @@ -31,11 +31,6 @@ def run_command(binary, args, output): | ... | @@ -31,11 +31,6 @@ def run_command(binary, args, output): |
| 31 | def kill_at(binary, args, phase, output, receipt_window=None): | 31 | def kill_at(binary, args, phase, output, receipt_window=None): |
| 32 | events = queue.Queue() | 32 | events = queue.Queue() |
| 33 | database = Path(args[1]) / 'cache.sqlite' | 33 | database = Path(args[1]) / 'cache.sqlite' |
| 34 | journal = database.with_name(database.name + '-journal') | ||
| 35 | def header(): | ||
| 36 | try: | ||
| 37 | with journal.open('rb') as stream: return stream.read(8) | ||
| 38 | except FileNotFoundError: return b'' | ||
| 39 | with output.with_suffix('.jsonl').open('w') as log, output.with_suffix('.stderr').open('w') as error: | 34 | with output.with_suffix('.jsonl').open('w') as log, output.with_suffix('.stderr').open('w') as error: |
| 40 | process = subprocess.Popen([str(binary), *map(str, args)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=error, text=True, | 35 | process = subprocess.Popen([str(binary), *map(str, args)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=error, text=True, |
| 41 | env={**os.environ, 'ONESTORE_RECOVERY_PAUSE': phase}) | 36 | env={**os.environ, 'ONESTORE_RECOVERY_PAUSE': phase}) |
| ... | @@ -57,24 +52,26 @@ def kill_at(binary, args, phase, output, receipt_window=None): | ... | @@ -57,24 +52,26 @@ def kill_at(binary, args, phase, output, receipt_window=None): |
| 57 | if event.get('event') == 'phase' and event['name'] == phase: | 52 | if event.get('event') == 'phase' and event['name'] == phase: |
| 58 | if receipt_window is not None: | 53 | if receipt_window is not None: |
| 59 | assert args[0] == 'sync' and phase == 'publish-after' | 54 | assert args[0] == 'sync' and phase == 'publish-after' |
| 60 | assert receipt_window in ('journal', 'database') | 55 | assert receipt_window == 'wal' |
| 61 | before_mtime = database.stat().st_mtime_ns | 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) | ||
| 62 | process.stdin.write('\n') | 59 | process.stdin.write('\n') |
| 63 | process.stdin.flush() | 60 | process.stdin.flush() |
| 64 | deadline = time.monotonic() + 5 | 61 | deadline = time.monotonic() + 5 |
| 65 | while True: | 62 | while True: |
| 66 | if header() == bytes.fromhex('d9d505f920a163d7') and (receipt_window == 'journal' or database.stat().st_mtime_ns != before_mtime): break | 63 | commits, pending = cache_images.wal_commits(database) |
| 67 | assert process.poll() is None, 'Receipt transaction finished before the requested cut' | 64 | if commits == committed and pending: break |
| 65 | assert commits == committed and process.poll() is None, 'Receipt transaction finished before the requested cut' | ||
| 68 | assert time.monotonic() < deadline, 'Receipt transaction window was not observed' | 66 | assert time.monotonic() < deadline, 'Receipt transaction window was not observed' |
| 69 | time.sleep(.0001) | ||
| 70 | process.kill() | 67 | process.kill() |
| 71 | break | 68 | break |
| 72 | assert process.wait(timeout=10) == -signal.SIGKILL, 'Expected an actual process kill' | 69 | assert process.wait(timeout=10) == -signal.SIGKILL, 'Expected an actual process kill' |
| 73 | proof = {'pid': process.pid, 'exit': process.returncode, 'phase': phase} | 70 | proof = {'pid': process.pid, 'exit': process.returncode, 'phase': phase} |
| 74 | if receipt_window is not None: | 71 | if receipt_window is not None: |
| 75 | proof.update(journal_header_after_kill=header().hex(), database_changed=database.stat().st_mtime_ns != before_mtime, receipt_window=receipt_window) | 72 | commits, pending = cache_images.wal_commits(database) |
| 76 | assert proof['journal_header_after_kill'] == 'd9d505f920a163d7', 'Receipt transaction committed before the process died' | 73 | proof.update(wal_commits_before=committed, wal_commits_after_kill=commits, uncommitted_frames=pending, receipt_window=receipt_window) |
| 77 | assert receipt_window != 'database' or proof['database_changed'] | 74 | assert commits == committed, 'Receipt transaction committed before the process died' |
| 78 | output.with_suffix('.cut.json').write_text(json.dumps(proof, indent=2)) | 75 | output.with_suffix('.cut.json').write_text(json.dumps(proof, indent=2)) |
| 79 | finally: | 76 | finally: |
| 80 | if process.poll() is None: process.kill() | 77 | if process.poll() is None: process.kill() |
| ... | @@ -103,7 +100,7 @@ def state(rows, original): | ... | @@ -103,7 +100,7 @@ def state(rows, original): |
| 103 | else: | 100 | else: |
| 104 | assert len(result['pending']) == 1, 'Unacknowledged intent was lost or duplicated' | 101 | assert len(result['pending']) == 1, 'Unacknowledged intent was lost or duplicated' |
| 105 | pending, = result['pending'] | 102 | pending, = result['pending'] |
| 106 | assert pending['id'] == 1 and pending['before'] == original and pending['replacement'] == TOKEN | 103 | assert pending['id'] == 1 and pending['replacement'] == TOKEN |
| 107 | at = len(original.encode('utf-16-le')) // 2 | 104 | at = len(original.encode('utf-16-le')) // 2 |
| 108 | assert pending['range'] == [at, at], 'Durable intent range changed' | 105 | assert pending['range'] == [at, at], 'Durable intent range changed' |
| 109 | return result | 106 | return result |
| ... | @@ -187,11 +184,12 @@ def run(source, output): | ... | @@ -187,11 +184,12 @@ def run(source, output): |
| 187 | try: | 184 | try: |
| 188 | assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)] | 185 | assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)] |
| 189 | assert connection.execute('PRAGMA foreign_key_check').fetchall() == [] | 186 | assert connection.execute('PRAGMA foreign_key_check').fetchall() == [] |
| 190 | local_image = save_image(output, cache_images.working(connection)) | 187 | # The image the queue applies to; the local text is the probe's, checked above. |
| 188 | base_image = save_image(output, cache_images.image(connection)) | ||
| 191 | finally: | 189 | finally: |
| 192 | connection.close() | 190 | connection.close() |
| 193 | result = {'case': index, 'phase': phase, 'during_confirmation': confirmation, 'before_status': before['status'], 'after_status': after['status'], | 191 | result = {'case': index, 'phase': phase, 'during_confirmation': confirmation, 'before_status': before['status'], 'after_status': after['status'], |
| 194 | 'visible_before_recovery': before['remote_text'] != original, 'remote_image': remote_image, 'local_image': local_image, | 192 | 'visible_before_recovery': before['remote_text'] != original, 'remote_image': remote_image, 'base_image': base_image, |
| 195 | 'remote_text': after['remote_text'], 'local_text': after['local_text']} | 193 | 'remote_text': after['remote_text'], 'local_text': after['local_text']} |
| 196 | results.append(result) | 194 | results.append(result) |
| 197 | (output / 'results.json').write_text(json.dumps(results, indent=2)) | 195 | (output / 'results.json').write_text(json.dumps(results, indent=2)) |
| ... | @@ -206,14 +204,14 @@ def run(source, output): | ... | @@ -206,14 +204,14 @@ def run(source, output): |
| 206 | def receipt_windows(source, output): | 204 | def receipt_windows(source, output): |
| 207 | binary, source_hash = prepare_run(source, output) | 205 | binary, source_hash = prepare_run(source, output) |
| 208 | results = [] | 206 | results = [] |
| 209 | for window in ('journal', 'database'): | 207 | for window in ('wal',): |
| 210 | folder = output / window | 208 | folder = output / window |
| 211 | initialized = run_command(binary, ['init', folder, source], output / (window+'-init')) | 209 | initialized = run_command(binary, ['init', folder, source], output / (window+'-init')) |
| 212 | original = next(row['remote_text'] for row in initialized if row['event'] == 'state') | 210 | original = next(row['remote_text'] for row in initialized if row['event'] == 'state') |
| 213 | state(initialized, original) | 211 | state(initialized, original) |
| 214 | kill_at(binary, ['sync', folder], 'publish-after', output / (window+'-kill'), receipt_window=window) | 212 | kill_at(binary, ['sync', folder], 'publish-after', output / (window+'-kill'), receipt_window=window) |
| 215 | before = state(run_command(binary, ['inspect', folder], output / (window+'-inspect')), original) | 213 | before = state(run_command(binary, ['inspect', folder], output / (window+'-inspect')), original) |
| 216 | assert before['status'] == 'uncertain' and before['remote_text'] == original+TOKEN, 'Hot-journal recovery lost the pending confirmation' | 214 | assert before['status'] == 'uncertain' and before['remote_text'] == original+TOKEN, 'Log recovery lost the pending confirmation' |
| 217 | before_bytes = (folder / 'remote.one').read_bytes() | 215 | before_bytes = (folder / 'remote.one').read_bytes() |
| 218 | recovered = run_command(binary, ['sync', folder], output / (window+'-recover')) | 216 | recovered = run_command(binary, ['sync', folder], output / (window+'-recover')) |
| 219 | after = state(recovered, original) | 217 | after = state(recovered, original) |
| ... | @@ -226,7 +224,7 @@ def receipt_windows(source, output): | ... | @@ -226,7 +224,7 @@ def receipt_windows(source, output): |
| 226 | print(json.dumps({'receipt_window':window, 'status':after['status'], 'remote_image':digest}), flush=True) | 224 | print(json.dumps({'receipt_window':window, 'status':after['status'], 'remote_image':digest}), flush=True) |
| 227 | assert hashlib.sha256(source.read_bytes()).hexdigest() == source_hash | 225 | assert hashlib.sha256(source.read_bytes()).hexdigest() == source_hash |
| 228 | (output/'results.json').write_text(json.dumps(results, indent=2)) | 226 | (output/'results.json').write_text(json.dumps(results, indent=2)) |
| 229 | (output/'summary.json').write_text(json.dumps({'process_kills':2, 'recovered_receipts':2, 'republished_edits':0}, indent=2)) | 227 | (output/'summary.json').write_text(json.dumps({'process_kills':len(results), 'recovered_receipts':len(results), 'republished_edits':0}, indent=2)) |
| 230 | 228 | ||
| 231 | 229 | ||
| 232 | if __name__ == '__main__': | 230 | if __name__ == '__main__': |
tools/test_offline_history.py+5-4| ... | @@ -139,9 +139,10 @@ class OfflineLedgerTests(unittest.TestCase): | ... | @@ -139,9 +139,10 @@ class OfflineLedgerTests(unittest.TestCase): |
| 139 | events.extend({'event': 'reopened_receipt', 'id': op+1, 'revision': f'{actor}-{op}'} for op in range(2)) | 139 | events.extend({'event': 'reopened_receipt', 'id': op+1, 'revision': f'{actor}-{op}'} for op in range(2)) |
| 140 | events.append({'event': 'done'}) | 140 | events.append({'event': 'done'}) |
| 141 | connection = sqlite3.connect(output / 'rust' / f'{actor}.sqlite') | 141 | connection = sqlite3.connect(output / 'rust' / f'{actor}.sqlite') |
| 142 | connection.executescript('CREATE TABLE receipts(edit_id INTEGER, revision TEXT); CREATE TABLE edits(id INTEGER); CREATE TABLE attempt(id INTEGER); CREATE TABLE conflicts(id INTEGER); CREATE TABLE replica(id INTEGER, base BLOB, working BLOB);') | 142 | # Schema 15 in WAL mode, as a drained cache leaves it. |
| 143 | connection.executescript('PRAGMA journal_mode=WAL; CREATE TABLE receipts(edit_id INTEGER, revision TEXT); CREATE TABLE edits(id INTEGER); CREATE TABLE batches(id INTEGER); CREATE TABLE payloads(sha256 BLOB); CREATE TABLE base(chunk INTEGER, bytes BLOB); CREATE TABLE remote(chunk INTEGER, bytes BLOB);') | ||
| 143 | connection.executemany('INSERT INTO receipts VALUES (?,?)', [(op+1, f'{actor}-{op}') for op in range(2)]) | 144 | connection.executemany('INSERT INTO receipts VALUES (?,?)', [(op+1, f'{actor}-{op}') for op in range(2)]) |
| 144 | connection.execute('INSERT INTO replica VALUES (1, ?, ?)', (b'opaque image', b'')) | 145 | connection.execute('INSERT INTO base VALUES (0, ?)', (b'opaque image',)) |
| 145 | connection.commit() | 146 | connection.commit() |
| 146 | connection.close() | 147 | connection.close() |
| 147 | for i in range(4): | 148 | for i in range(4): |
| ... | @@ -165,8 +166,8 @@ class OfflineLedgerTests(unittest.TestCase): | ... | @@ -165,8 +166,8 @@ class OfflineLedgerTests(unittest.TestCase): |
| 165 | os.utime(output / 'rust/stop', ns=(0, 0)) | 166 | os.utime(output / 'rust/stop', ns=(0, 0)) |
| 166 | connection = sqlite3.connect(output / 'rust/w0.sqlite') | 167 | connection = sqlite3.connect(output / 'rust/w0.sqlite') |
| 167 | for sql, undo in [("UPDATE receipts SET revision='wrong' WHERE edit_id=1", "UPDATE receipts SET revision='w0-0' WHERE edit_id=1"), | 168 | for sql, undo in [("UPDATE receipts SET revision='wrong' WHERE edit_id=1", "UPDATE receipts SET revision='w0-0' WHERE edit_id=1"), |
| 168 | ('INSERT INTO attempt VALUES (1)', 'DELETE FROM attempt'), | 169 | ('INSERT INTO batches VALUES (1)', 'DELETE FROM batches'), |
| 169 | ("UPDATE replica SET working=X'00'", "UPDATE replica SET working=X''")]: | 170 | ("INSERT INTO remote VALUES (0, X'00')", 'DELETE FROM remote')]: |
| 170 | connection.execute(sql) | 171 | connection.execute(sql) |
| 171 | connection.commit() | 172 | connection.commit() |
| 172 | with self.assertRaises(AssertionError): verify(output) | 173 | with self.assertRaises(AssertionError): verify(output) |
tools/test_offline_publication_crash.py+2-2| ... | @@ -28,7 +28,7 @@ class RecoveryOracleTests(unittest.TestCase): | ... | @@ -28,7 +28,7 @@ class RecoveryOracleTests(unittest.TestCase): |
| 28 | at = len(original.encode('utf-16-le')) // 2 | 28 | at = len(original.encode('utf-16-le')) // 2 |
| 29 | row = {'event': 'state', 'status': 'uncertain', 'revision': 'new', 'remote_revision': 'old', | 29 | row = {'event': 'state', 'status': 'uncertain', 'revision': 'new', 'remote_revision': 'old', |
| 30 | 'local_text': original+TOKEN, 'remote_text': original, | 30 | 'local_text': original+TOKEN, 'remote_text': original, |
| 31 | 'pending': [{'id': 1, 'before': original, 'replacement': TOKEN, 'range': [at, at]}]} | 31 | 'pending': [{'id': 1, 'replacement': TOKEN, 'range': [at, at]}]} |
| 32 | self.assertEqual(state([row], original), row) | 32 | self.assertEqual(state([row], original), row) |
| 33 | for field, value in [('revision', None), ('revision', 'old'), ('local_text', original), | 33 | for field, value in [('revision', None), ('revision', 'old'), ('local_text', original), |
| 34 | ('remote_text', original+TOKEN+TOKEN), ('status', 'missing'), ('pending', [])]: | 34 | ('remote_text', original+TOKEN+TOKEN), ('status', 'missing'), ('pending', [])]: |
| ... | @@ -36,7 +36,7 @@ class RecoveryOracleTests(unittest.TestCase): | ... | @@ -36,7 +36,7 @@ class RecoveryOracleTests(unittest.TestCase): |
| 36 | changed[field] = value | 36 | changed[field] = value |
| 37 | with self.subTest(field=field): | 37 | with self.subTest(field=field): |
| 38 | with self.assertRaises(AssertionError): state([changed], original) | 38 | with self.assertRaises(AssertionError): state([changed], original) |
| 39 | for field, value in [('id', 2), ('before', 'other'), ('replacement', 'other'), ('range', [at-1, at-1])]: | 39 | for field, value in [('id', 2), ('replacement', 'other'), ('range', [at-1, at-1])]: |
| 40 | changed = copy.deepcopy(row) | 40 | changed = copy.deepcopy(row) |
| 41 | changed['pending'][0][field] = value | 41 | changed['pending'][0][field] = value |
| 42 | with self.assertRaises(AssertionError): state([changed], original) | 42 | with self.assertRaises(AssertionError): state([changed], original) |
tools/verify_offline.py+3-3| ... | @@ -4,6 +4,7 @@ import argparse | ... | @@ -4,6 +4,7 @@ import argparse |
| 4 | import hashlib | 4 | import hashlib |
| 5 | import json | 5 | import json |
| 6 | from pathlib import Path | 6 | from pathlib import Path |
| 7 | import cache_images | ||
| 7 | import sqlite3 | 8 | import sqlite3 |
| 8 | 9 | ||
| 9 | from native_stress import edit_history, native_history | 10 | from native_stress import edit_history, native_history |
| ... | @@ -41,12 +42,11 @@ def verify(output, max_gap=120): | ... | @@ -41,12 +42,11 @@ def verify(output, max_gap=120): |
| 41 | try: | 42 | try: |
| 42 | assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)], 'Cache integrity failed' | 43 | assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)], 'Cache integrity failed' |
| 43 | assert connection.execute('PRAGMA foreign_key_check').fetchall() == [], 'Cache foreign keys failed' | 44 | assert connection.execute('PRAGMA foreign_key_check').fetchall() == [], 'Cache foreign keys failed' |
| 44 | for table in ('edits', 'attempt', 'conflicts'): | 45 | for table in ('edits', 'batches', 'payloads', 'remote'): |
| 45 | assert connection.execute(f'SELECT count(*) FROM {table}').fetchone() == (0,), 'Completed cache retains unresolved state' | 46 | assert connection.execute(f'SELECT count(*) FROM {table}').fetchone() == (0,), 'Completed cache retains unresolved state' |
| 46 | persisted = dict(connection.execute('SELECT edit_id, revision FROM receipts')) | 47 | persisted = dict(connection.execute('SELECT edit_id, revision FROM receipts')) |
| 47 | assert persisted == {id: event['revision'] for id, event in receipts.items()}, 'SQLite receipts differ from observed acknowledgements' | 48 | assert persisted == {id: event['revision'] for id, event in receipts.items()}, 'SQLite receipts differ from observed acknowledgements' |
| 48 | base, working = connection.execute('SELECT base, working FROM replica WHERE id=1').fetchone() | 49 | base = cache_images.image(connection) |
| 49 | assert working == b'', 'A drained cache retained a divergent local branch' | ||
| 50 | caches[actor] = {'receipts': len(persisted), 'image_bytes': len(base), 'image_sha256': hashlib.sha256(base).hexdigest(), 'database_sha256': hashlib.sha256(path.read_bytes()).hexdigest()} | 50 | caches[actor] = {'receipts': len(persisted), 'image_bytes': len(base), 'image_sha256': hashlib.sha256(base).hexdigest(), 'database_sha256': hashlib.sha256(path.read_bytes()).hexdigest()} |
| 51 | finally: | 51 | finally: |
| 52 | connection.close() | 52 | connection.close() |