| author | |
| committer | |
| log | 09ee1d6454d9862cd2b895fea88eee8541e6caaf |
| tree | 433e95082bcad582bc7baa4c4f7bdc1499850f98 |
| parent | 1dea3f70f427bd13315c88c22cab8f539a791187 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
One application-facing crate: notebook discovery (notebook::discover), the
SQLite replica with reconciliation, assets and recovery at the crate root,
embedded SMB access behind the smb feature (notebook::smb, including the
SmbRemote adapter), and the onestore-diagnostic bridge as a binary. Tools,
fuzz targets and documentation follow the new paths; the diagnostic binary
keeps its name for the Python tooling.
Validation: 457 workspace tests (nextest), 5 README doctests, Clippy and
fmt; the fuzz workspace builds.
Assisted-by: claude-fable-5.1101 files changed, 16954 insertions(+), 17023 deletions(-)
Cargo.lock+16-48| ... | @@ -1561,6 +1561,22 @@ dependencies = [ | ... | @@ -1561,6 +1561,22 @@ dependencies = [ |
| 1561 | "libc", | 1561 | "libc", |
| 1562 | ] | 1562 | ] |
| 1563 | 1563 | ||
| 1564 | [[package]] | ||
| 1565 | name = "notebook" | ||
| 1566 | version = "0.1.0" | ||
| 1567 | dependencies = [ | ||
| 1568 | "onestore", | ||
| 1569 | "rusqlite", | ||
| 1570 | "serde", | ||
| 1571 | "serde_json", | ||
| 1572 | "sha2", | ||
| 1573 | "smb2", | ||
| 1574 | "tempfile", | ||
| 1575 | "thiserror 2.0.20", | ||
| 1576 | "tokio", | ||
| 1577 | "zeroize", | ||
| 1578 | ] | ||
| 1579 | |||
| 1564 | [[package]] | 1580 | [[package]] |
| 1565 | name = "num-traits" | 1581 | name = "num-traits" |
| 1566 | version = "0.2.19" | 1582 | version = "0.2.19" |
| ... | @@ -1923,54 +1939,6 @@ dependencies = [ | ... | @@ -1923,54 +1939,6 @@ dependencies = [ |
| 1923 | "zeroize", | 1939 | "zeroize", |
| 1924 | ] | 1940 | ] |
| 1925 | 1941 | ||
| 1926 | [[package]] | ||
| 1927 | name = "onestore-diagnostic" | ||
| 1928 | version = "0.1.0" | ||
| 1929 | dependencies = [ | ||
| 1930 | "onestore", | ||
| 1931 | "onestore-notebook", | ||
| 1932 | "serde", | ||
| 1933 | "serde_json", | ||
| 1934 | ] | ||
| 1935 | |||
| 1936 | [[package]] | ||
| 1937 | name = "onestore-notebook" | ||
| 1938 | version = "0.1.0" | ||
| 1939 | dependencies = [ | ||
| 1940 | "onestore", | ||
| 1941 | "onestore-smb", | ||
| 1942 | "serde", | ||
| 1943 | "serde_json", | ||
| 1944 | "tempfile", | ||
| 1945 | "thiserror 2.0.20", | ||
| 1946 | "zeroize", | ||
| 1947 | ] | ||
| 1948 | |||
| 1949 | [[package]] | ||
| 1950 | name = "onestore-offline" | ||
| 1951 | version = "0.1.0" | ||
| 1952 | dependencies = [ | ||
| 1953 | "onestore", | ||
| 1954 | "onestore-notebook", | ||
| 1955 | "onestore-smb", | ||
| 1956 | "rusqlite", | ||
| 1957 | "serde", | ||
| 1958 | "serde_json", | ||
| 1959 | "sha2", | ||
| 1960 | "tempfile", | ||
| 1961 | "thiserror 2.0.20", | ||
| 1962 | ] | ||
| 1963 | |||
| 1964 | [[package]] | ||
| 1965 | name = "onestore-smb" | ||
| 1966 | version = "0.1.0" | ||
| 1967 | dependencies = [ | ||
| 1968 | "onestore", | ||
| 1969 | "serde_json", | ||
| 1970 | "smb2", | ||
| 1971 | "tokio", | ||
| 1972 | ] | ||
| 1973 | |||
| 1974 | [[package]] | 1942 | [[package]] |
| 1975 | name = "orbclient" | 1943 | name = "orbclient" |
| 1976 | version = "0.3.55" | 1944 | version = "0.3.55" |
crates/notebook/Cargo.toml created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | [package] | ||
| 2 | name = "notebook" | ||
| 3 | version = "0.1.0" | ||
| 4 | edition = "2024" | ||
| 5 | publish = false | ||
| 6 | |||
| 7 | [features] | ||
| 8 | smb = ["dep:smb2", "dep:tokio"] | ||
| 9 | protected = ["onestore/protected", "dep:zeroize"] | ||
| 10 | |||
| 11 | [dependencies] | ||
| 12 | onestore = { path = "../onestore" } | ||
| 13 | rusqlite = { version = "=0.40.2", features = ["bundled", "backup"] } | ||
| 14 | thiserror = "2" | ||
| 15 | serde = { version = "1", features = ["derive"] } | ||
| 16 | serde_json = "1" | ||
| 17 | tempfile = "3" | ||
| 18 | sha2 = "0.11" | ||
| 19 | smb2 = { version = "=0.21.0", optional = true } | ||
| 20 | tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true } | ||
| 21 | zeroize = { version = "1.9.0", optional = true } | ||
| 22 | |||
| 23 | [dev-dependencies] | ||
| 24 | serde_json = "1" | ||
| 25 | tempfile = "3" | ||
| 26 | |||
| 27 | [[example]] | ||
| 28 | name = "smb_offline_client" | ||
| 29 | required-features = ["smb"] | ||
| 30 | |||
| 31 | [[example]] | ||
| 32 | name = "smb_reconnect_client" | ||
| 33 | required-features = ["smb"] | ||
| 34 | |||
| 35 | [[example]] | ||
| 36 | name = "smb_concurrent_client" | ||
| 37 | required-features = ["smb"] | ||
crates/notebook/README.md created+415| ... | @@ -0,0 +1,415 @@ | ||
| 1 | # notebook | ||
| 2 | |||
| 3 | The application-facing crate: notebook discovery, a durable local replica with | ||
| 4 | reconnect reconciliation, external-asset caching, recovery export and optional | ||
| 5 | embedded SMB access. Durable local editing for a OneNote file: the current | ||
| 6 | foundation stores a complete working image and typed editing intents in a local | ||
| 7 | SQLite database. `sync_once` provides a reconciliation step and `start_sync` owns | ||
| 8 | automatic polling and reconnects. Local success does not | ||
| 9 | acknowledge publication to a shared notebook. | ||
| 10 | |||
| 11 | ```no_run | ||
| 12 | use onestore::ExGuid; | ||
| 13 | use notebook::Replica; | ||
| 14 | # fn example(path: &std::path::Path, source: &[u8], space: ExGuid, text: ExGuid) | ||
| 15 | # -> Result<(), Box<dyn std::error::Error>> { | ||
| 16 | // `space` and `text` are identities from the supplied section's document model. | ||
| 17 | let cache = Replica::create(path, source)?; | ||
| 18 | let snapshot = cache.snapshot()?; | ||
| 19 | let local_id = cache.edit_text(&snapshot, space, text, 0..0, "Offline edit ")?; | ||
| 20 | drop(cache); | ||
| 21 | |||
| 22 | let reopened = Replica::open(path)?; | ||
| 23 | let current = reopened.snapshot()?; | ||
| 24 | let pending = reopened.pending()?; | ||
| 25 | assert_eq!(pending.last().map(|edit| edit.id), local_id); | ||
| 26 | # Ok(()) | ||
| 27 | # } | ||
| 28 | ``` | ||
| 29 | |||
| 30 | `insert` accepts the core library's `Insertion` value and durably retains its | ||
| 31 | intent identities. `Insertion::with_formatting` queues text and | ||
| 32 | its styles as one intent and one publication. Keep that value across retries; its `text_object()` identifies | ||
| 33 | the new text for subsequent offline edits. Pending entries expose | ||
| 34 | `Operation::Text(TextEdit)`, `Operation::Insert(Insertion)`, | ||
| 35 | `Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)`, | ||
| 36 | `Operation::Join(JoinEdit)`, `Operation::Outline(OutlineEdit)`, | ||
| 37 | `Operation::Tree(TreeEdit)`, `Operation::CreatePage(PageCreation)` and | ||
| 38 | `Operation::Pages(PageEdits)` through their | ||
| 39 | `operation` field. Synchronization applies these in queue order, so an inserted | ||
| 40 | outline can precede its paragraphs and their later edits. Missing anchors or | ||
| 41 | existing insertion identities preserve a conflict and the complete local image. | ||
| 42 | |||
| 43 | `create_page` accepts the core `PageCreation` intent and queues its page space | ||
| 44 | and section entry as one publication. Subsequent outlines and title edits use | ||
| 45 | the intent's stable identities immediately after local acknowledgement. | ||
| 46 | Independent page additions rebase against the current section order; duplicate | ||
| 47 | titles remain distinct. An unavailable or no-longer-leading insertion anchor | ||
| 48 | produces `StructureChanged`. `rebase_page_creation_conflict(id, local, remote, | ||
| 49 | before)` reviews a replacement anchor against both cache images while retaining | ||
| 50 | the new page and dependent object identities. Existing page identities require | ||
| 51 | reconciliation; a matching page alone does not establish a receipt. | ||
| 52 | |||
| 53 | `pages(snapshot, edits)` queues a slice of core `PageEdit` intents as one atomic | ||
| 54 | publication. `PagePosition::Keep` preserves remote movement during indentation-only | ||
| 55 | edits. Every requested level remains explicit: a competing remote level produces | ||
| 56 | `StructureChanged` unless it already matches the requested level. Moves compare | ||
| 57 | the selected page's position relative to surviving observed pages; an unchanged | ||
| 58 | or already-satisfied position can proceed, and new remote pages remain present. | ||
| 59 | Indistinguishable competing moves retain a conflict and the complete local image. | ||
| 60 | |||
| 61 | `rebase_pages_conflict(id, local, remote, edits)` reviews the batch against both | ||
| 62 | cache images. Use `PageEdit::reposition(position, level)` on the retained intents | ||
| 63 | to revise anchors or indentation; replacing page or allocated series identities | ||
| 64 | is rejected. Dependent edits remain queued. Schema 11 adds the durable batch; | ||
| 65 | schema 10 migration preserves existing multi-space publication evidence unchanged. | ||
| 66 | Attempt evidence includes every changed space plus the receipt space when that | ||
| 67 | space is unchanged. An existing section revision alone cannot confirm a page edit. | ||
| 68 | The [offline page corpus](../../corpus/page-lifecycle/offline-edits/README.md) | ||
| 69 | contains native comparisons, reproduction commands and stateful sanitizer seeds. | ||
| 70 | |||
| 71 | `split` and `join` accept the core `ParagraphSplit` and `ParagraphJoin` intents. | ||
| 72 | Splits retain allocated identities when the original UTF-16 boundary rebases; | ||
| 73 | dependent edits can address `ParagraphSplit::text_object()` immediately after | ||
| 74 | local acknowledgement. Both operations retain observed parent paths, children, | ||
| 75 | indentation, paragraph/list state and tags. Changed structure produces | ||
| 76 | `StructureChanged`. Remote text changes must leave unambiguous boundary mappings; | ||
| 77 | current remote character styles are preserved. Reconciliation does not silently | ||
| 78 | discard a newly added right tag or move an unobserved child. | ||
| 79 | |||
| 80 | `rebase_conflict` accepts a zero-length reviewed range for a split, preserving its | ||
| 81 | allocated identities. `rebase_join_conflict(id, local, remote)` reviews the original | ||
| 82 | join against current images. It rejects a change in which text identity survives, | ||
| 83 | because dependent edits still address that identity. As with other conflict | ||
| 84 | reviews, stale images, pending operations and uncertain attempts cannot be rebased. | ||
| 85 | An uncertain structural operation requires its original attempted revision for | ||
| 86 | confirmation; matching text or structure does not establish a receipt. | ||
| 87 | |||
| 88 | `rebase_paragraph_conflict(id, local, remote, parent, before)` and | ||
| 89 | `rebase_outline_conflict(id, local, remote, page, x, y)` accept reviewed replacement | ||
| 90 | placements for the oldest insertion conflict. They preserve creation time, text, | ||
| 91 | author and object identities, keeping later edits attached to their original targets. | ||
| 92 | The images must still match the cache; the new placement must be valid in the remote | ||
| 93 | image. Paragraph intents cannot become outlines or vice versa. Pending edits and | ||
| 94 | uncertain publication attempts cannot be repositioned through conflict review. | ||
| 95 | |||
| 96 | ```no_run | ||
| 97 | use onestore::{ExGuid, Insertion, TextAttribute}; | ||
| 98 | use notebook::{EditStatus, Replica}; | ||
| 99 | # fn add_outline(cache: &Replica, space: ExGuid, page: ExGuid) | ||
| 100 | # -> Result<(), Box<dyn std::error::Error>> { | ||
| 101 | let outline = Insertion::outline(page, 144.0, 216.0, "Offline outline", "Author")? | ||
| 102 | .with_formatting(0..7, &[TextAttribute::Bold(true)])?; | ||
| 103 | let id = cache.insert(&cache.snapshot()?, space, &outline)?; | ||
| 104 | if let Some(id) = id { | ||
| 105 | // A running worker may already have advanced this state. | ||
| 106 | match cache.status(id)? { | ||
| 107 | Some(EditStatus::Published { revision }) => println!("{revision}"), | ||
| 108 | state => println!("{state:?}"), | ||
| 109 | } | ||
| 110 | } | ||
| 111 | # Ok(()) | ||
| 112 | # } | ||
| 113 | ``` | ||
| 114 | |||
| 115 | `format` accepts the core `TextAttribute` slice and a UTF-16 range. Its durable intent | ||
| 116 | retains the observed text and selected attribute values. Remote text changes must | ||
| 117 | leave an unambiguous range; independent remote attributes merge, while competing | ||
| 118 | values preserve `FormattingChanged`. A remote value that already matches the | ||
| 119 | requested value is accepted. Enabling superscript or subscript also checks the | ||
| 120 | opposite attribute that the operation clears. If the remote image already satisfies | ||
| 121 | the whole operation, guarded confirmation still precedes a durable receipt. | ||
| 122 | |||
| 123 | `outline` accepts a target object and the core `onestore::OutlineEdit`: outline | ||
| 124 | position/width or a paragraph's saved collapse default. It retains the target's | ||
| 125 | ancestry and the properties the operation changes. Independent content, formatting | ||
| 126 | and layout properties merge; competing selected values produce `LayoutChanged`, | ||
| 127 | and changed ancestry produces `StructureChanged`. Width changes also check the | ||
| 128 | reserved wrapping width that they clear. Missing targets preserve the local branch. | ||
| 129 | `rebase_layout_conflict(id, local, remote)` explicitly reviews the original change | ||
| 130 | against both current images, preserving dependent intents. An uncertain layout | ||
| 131 | attempt requires its original revision; converged values cannot establish its receipt. | ||
| 132 | |||
| 133 | `tree` accepts the core `onestore::TreeEdit`. Moves preserve independent remote | ||
| 134 | text, formatting and descendants. A competing ancestor, indentation or sibling | ||
| 135 | crossing produces `StructureChanged`; unrelated sibling insertions/deletions can | ||
| 136 | merge. An anchor must remain a direct child of the requested destination. An | ||
| 137 | already satisfied move still requires guarded confirmation before acknowledgement. | ||
| 138 | Deletion compares the selected raw property graph, including referenced styles, | ||
| 139 | tags, unknown fields and internal attachments. Changed content produces | ||
| 140 | `ContentChanged`; property order, CompactID numbering and modification timestamps | ||
| 141 | do not affect that comparison. Immutable records compare by content, and empty | ||
| 142 | child lists compare equally to absent child lists. Mutable object identities | ||
| 143 | remain significant. Missing targets retain `TargetUnavailable`. | ||
| 144 | `rebase_tree_conflict(id, local, remote)` reviews the original move/deletion against | ||
| 145 | both current images. An emptied cell's replacement paragraph/text identities must | ||
| 146 | remain the same before replay or review; later queued edits keep their targets. | ||
| 147 | Uncertain tree attempts require their original revision for confirmation, even | ||
| 148 | when an independent move or deletion has the same visible effect. | ||
| 149 | |||
| 150 | Recognized earlier caches migrate transactionally to version ten. Publication | ||
| 151 | attempts retain every changed space's revision; legacy attempts retain their | ||
| 152 | single-space evidence. Earlier recovery archives remain readable without migration. | ||
| 153 | Version-eight | ||
| 154 | deletion observations retain their original identity-sensitive preconditions; | ||
| 155 | explicit conflict review upgrades them to the immutable-content comparison. | ||
| 156 | The migration retains images, local IDs, publication attempts, conflicts, | ||
| 157 | receipts and the autoincrement sequence; it does not reuse acknowledged IDs when | ||
| 158 | the pending queue is empty. | ||
| 159 | |||
| 160 | Share one `Replica` between application threads. Each edit compares its supplied | ||
| 161 | snapshot under the cache transaction; stale snapshots return `Io(ResourceBusy)`. | ||
| 162 | The intent and its resulting image commit together. No-op edits return `None`. | ||
| 163 | Keep the cache on a local filesystem: the connection holds exclusive ownership | ||
| 164 | between transactions, and a second open fails busy. No network wait occurs in a | ||
| 165 | local edit. After a database error, reopen and inspect the durable state before | ||
| 166 | retrying. | ||
| 167 | |||
| 168 | `sync_once(&mut remote)` processes the oldest pending edit through a `Remote` | ||
| 169 | implementation, returning its ID and `EditStatus`. A durable `Published` receipt | ||
| 170 | survives reopening. Publication attempts are recorded before network I/O; a retained | ||
| 171 | attempt requires every recorded revision to remain present, followed by comparison, | ||
| 172 | flushing and refreshed header version | ||
| 173 | metadata before acknowledgement. If a formatting attempt's revision is missing, | ||
| 174 | the complete requested effect can instead be confirmed on a uniquely aligned | ||
| 175 | range; its receipt identifies that confirmed current revision. Otherwise the | ||
| 176 | missing attempt remains `AwaitingConfirmation`. Neither path replays an uncertain | ||
| 177 | publication. Overlapping or ambiguous edits retain `Conflict` status, their complete | ||
| 178 | local image and the last observed remote image returned by `remote_snapshot`. | ||
| 179 | Transport errors return `Error::Remote` or `Error::RemoteIo`; inspect `status(id)` | ||
| 180 | after the error to distinguish a retained attempt from a pending edit or receipt. | ||
| 181 | The error return does not roll back a locally acknowledged intent. | ||
| 182 | |||
| 183 | Rebasing accepts only character mappings shared by every minimum insertion/deletion | ||
| 184 | alignment. UTF-16 ranges must preserve Unicode scalar boundaries. A bounded | ||
| 185 | alignment search also leaves a conflict when it cannot establish a unique mapping. | ||
| 186 | The current operation processes one queue head; while edits remain, `snapshot` | ||
| 187 | preserves the complete local working image. An empty queue can refresh from the | ||
| 188 | remote image. Synchronization holds a separate owner lock, so local edits can | ||
| 189 | continue during network waits; competing synchronization calls return `WouldBlock`. | ||
| 190 | |||
| 191 | `rebase_conflict(id, local, remote, range)` lets a caller explicitly place the | ||
| 192 | oldest conflicting text or formatting intent at a reviewed UTF-16 range in the remote image. | ||
| 193 | The supplied images must still match `snapshot()` and `remote_snapshot()`. | ||
| 194 | It preserves the original replacement or requested attributes, intent ID, complete local working image | ||
| 195 | and every later intent; the selected range and remote paragraph become the | ||
| 196 | intent's new comparison base in one local transaction. Formatting also captures | ||
| 197 | the reviewed attribute values as its new precondition. This operation performs | ||
| 198 | no network I/O, clears the conflict to `Pending`, and wakes the worker. | ||
| 199 | Publication still reads the latest remote image and uses exact guarded comparison; | ||
| 200 | another overlapping remote edit can produce a new conflict. An uncertain attempt | ||
| 201 | cannot be rebased, and selecting text that already equals the replacement does | ||
| 202 | not create a publication acknowledgement. | ||
| 203 | |||
| 204 | With the optional `smb` feature, `SmbRemote::new(client, path, limit)` binds an | ||
| 205 | `notebook::smb::Client` to one share-relative file and snapshot limit. Remote identity uses the logical root | ||
| 206 | object space, which survives the tested native compaction that replaces the file ID. | ||
| 207 | |||
| 208 | An `Arc<Replica>` can own one background worker. Supply a connection factory, poll | ||
| 209 | interval and observer; successful publications drain immediately, durable local | ||
| 210 | edits wake the worker, and `wake()` requests an immediate reachability retry. | ||
| 211 | Transport failures discard the old connection and retry through the factory; | ||
| 212 | Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy` | ||
| 213 | reuse the connection. Contended `NotCommitted` operations use randomized backoff, | ||
| 214 | capped at one second, to separate competing retry cycles. Cancellation interrupts this delay; local wake notifications | ||
| 215 | remain coalesced until its end. | ||
| 216 | `RemoteIo` distinguishes connection/read failures from | ||
| 217 | local `Io` errors. Cache/document errors stop the worker. Observers receive every | ||
| 218 | attempt's result on the worker thread, including unchanged conflict/uncertain | ||
| 219 | statuses; durable edit state remains available through `status`. | ||
| 220 | |||
| 221 | ```no_run | ||
| 222 | # #[cfg(feature = "smb")] | ||
| 223 | # fn example(cache: std::sync::Arc<notebook::Replica>, username: String, password: String) | ||
| 224 | # -> Result<(), Box<dyn std::error::Error>> { | ||
| 225 | use notebook::SmbRemote; | ||
| 226 | use notebook::smb::{Client, Credentials}; | ||
| 227 | use std::time::Duration; | ||
| 228 | |||
| 229 | let worker = cache.start_sync( | ||
| 230 | Duration::from_secs(2), | ||
| 231 | move || { | ||
| 232 | Client::connect( | ||
| 233 | "server:445", "notes", | ||
| 234 | Credentials { username: &username, password: &password, domain: "" }, | ||
| 235 | Duration::from_secs(5), | ||
| 236 | ).map(|client| SmbRemote::new(client, "Personal/Video.one", 64 * 1024 * 1024)) | ||
| 237 | }, | ||
| 238 | |result| { | ||
| 239 | if let Err(error) = result { eprintln!("{error}"); } | ||
| 240 | }, | ||
| 241 | )?; | ||
| 242 | // Retain `worker` while synchronization should run; local edits wake it automatically. | ||
| 243 | worker.stop()?; | ||
| 244 | # Ok(()) | ||
| 245 | # } | ||
| 246 | ``` | ||
| 247 | |||
| 248 | `stop()` cancels future steps and joins the worker, returning a fatal cache error | ||
| 249 | or worker panic. Dropping it requests cancellation without waiting. An in-flight | ||
| 250 | step completes before releasing ownership; a replacement worker cannot start | ||
| 251 | while the old one still owns the replica. Remote operations and callbacks must | ||
| 252 | have bounded execution times if shutdown latency matters. A dropped worker can | ||
| 253 | briefly retain the cache; use `stop()` before requiring an immediate reopen. | ||
| 254 | Cancellation and application restart retain pending edits and publication attempts. | ||
| 255 | Credentials belong to the factory, not the cache database. | ||
| 256 | |||
| 257 | Creation refuses existing paths. Opening recognizes the application identity and | ||
| 258 | schema version, validates database integrity and both notebook images, and rejects | ||
| 259 | unsupported journal modes without converting them. Failed initialization preserves | ||
| 260 | the file for inspection. SQLite uses DELETE journaling, EXTRA synchronization and | ||
| 261 | fullfsync; each required setting is queried back. | ||
| 262 | |||
| 263 | The cache and its pending intents contain notebook content. The core `onestore` | ||
| 264 | crate stays independent of SQLite and network runtimes. Device and simulator | ||
| 265 | examples compile and link for iOS; recorded process-interruption tests do not | ||
| 266 | establish physical power-loss durability. Evidence is tracked in [Milestone 9](../../evidence/MILESTONE9.md). | ||
| 267 | |||
| 268 | The SMB-enabled `smb_offline_client` example is an owned-lab workload for | ||
| 269 | `tools/native_collaboration.py --offline --embedded-smb`. It separates local | ||
| 270 | acknowledgements, remote publication attempts, persisted receipts and cache reopen | ||
| 271 | checks. Its append-specific conflict review policy lives in the test client; | ||
| 272 | the library continues to preserve conflicts requiring an explicit decision. | ||
| 273 | |||
| 274 | ## Recovery archives | ||
| 275 | |||
| 276 | `export_recovery(new_path)` captures both complete notebook images, the typed | ||
| 277 | queue, uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one | ||
| 278 | SQLite snapshot. It refuses existing destinations and leaves the live queue | ||
| 279 | unchanged. Export to a local directory from a background thread: copying holds | ||
| 280 | the cache mutex while capturing the database. Failure after the final rename can | ||
| 281 | leave a complete archive at the requested path; it never acknowledges a remote | ||
| 282 | edit. Archives contain notebook content and use a separate database identity, so | ||
| 283 | `Replica::open` rejects them as writable caches. | ||
| 284 | |||
| 285 | ```no_run | ||
| 286 | use notebook::{Recovery, Replica}; | ||
| 287 | # fn example(cache: &Replica) -> Result<(), Box<dyn std::error::Error>> { | ||
| 288 | cache.export_recovery("review.sqlite")?; | ||
| 289 | let review = Recovery::open("review.sqlite")?; | ||
| 290 | let counts = review.summary()?; | ||
| 291 | let local = review.snapshot()?; | ||
| 292 | let remote = review.remote_snapshot()?; | ||
| 293 | let pending = review.pending()?; | ||
| 294 | let receipts = review.receipts()?; | ||
| 295 | # Ok(()) | ||
| 296 | # } | ||
| 297 | ``` | ||
| 298 | |||
| 299 | `Recovery` provides read-only inspection and no synchronization or restore method. | ||
| 300 | `status(id)` preserves the same state interpretation as the live replica. | ||
| 301 | `recovery_summary()` on the live replica and `summary()` on an archive return | ||
| 302 | counts and byte sizes without notebook text, paths, authors or credentials. | ||
| 303 | Opening an archive validates its schema and images without migration. A recovery | ||
| 304 | archive is evidence for a reviewed recovery decision, not a second active queue. | ||
| 305 | |||
| 306 | ## Downloaded media | ||
| 307 | |||
| 308 | `fetch_asset(source, section, filename, limit)` resolves a declared external | ||
| 309 | payload through `notebook::discover::Source` and durably caches its exact bytes. | ||
| 310 | The section path is relative to the source root; the filename comes from a | ||
| 311 | `FileDataReference::External` in the retained working or remote image. Local and | ||
| 312 | SMB sources use the same API. Downloads release the cache mutex during network | ||
| 313 | I/O and recheck the reference before committing; edits can continue meanwhile. | ||
| 314 | |||
| 315 | `cached_asset(filename, limit)` reads previously downloaded bytes without network | ||
| 316 | access. `None` means never downloaded; `Some(Vec::new())` is a downloaded empty | ||
| 317 | payload. Each read checks the stored SHA-256 and enforces the byte limit before | ||
| 318 | loading the payload. Cache contents describe the prior download, not current | ||
| 319 | server reachability or presence. A failed fetch returns its error without | ||
| 320 | silently substituting cached bytes. Different bytes for an already cached file | ||
| 321 | identity return `AssetChanged` and preserve the previous download. | ||
| 322 | |||
| 323 | Downloads do not change pending edits, publication attempts or receipts. Recovery | ||
| 324 | archives include cached media and expose the same bounded `cached_asset` lookup. | ||
| 325 | Schema-4 archives remain readable without migration and contain no media cache. | ||
| 326 | |||
| 327 | ## Discovery | ||
| 328 | |||
| 329 | `notebook::discover` provides read-only notebook discovery over a caller-supplied | ||
| 330 | root, keeping directory traversal out of the single-file storage parser. | ||
| 331 | |||
| 332 | Discovery returns ordered sections and nested groups with file identities and | ||
| 333 | share-relative paths. Section display-name overrides remain distinct from file | ||
| 334 | names. TOC references whose identities are absent from the directory remain | ||
| 335 | inspectable; a cached filename never substitutes for an identity match. | ||
| 336 | Reserved `_onefiles` directories are excluded from section-group traversal. | ||
| 337 | Encrypted sections and valid storage with an unreadable document graph retain | ||
| 338 | their identity as `Locked` or `Unreadable`, without being presented as empty pages. | ||
| 339 | Malformed storage, failed reads and ambiguous identities reject the discovery. | ||
| 340 | |||
| 341 | Each file read must be a consistent, bounded snapshot. The result is an | ||
| 342 | observation across multiple files, not an atomic notebook transaction or | ||
| 343 | authorization to publish an edit. Refresh rejects observed topology changes and | ||
| 344 | duplicate physical files with the same logical identity. Retain the last accepted | ||
| 345 | catalog if discovery fails; a connection failure does not mean files were deleted. | ||
| 346 | |||
| 347 | `read_external_asset` resolves a validated UUID `.onebin` filename beneath the | ||
| 348 | selected section's sibling `_onefiles` folder and returns exact bounded bytes. | ||
| 349 | Missing files, permissions and size failures retain their I/O error kinds; an | ||
| 350 | empty payload is a successful empty buffer. Embedded payloads remain available | ||
| 351 | directly from the core document model. | ||
| 352 | |||
| 353 | ## SMB (feature `smb`) | ||
| 354 | |||
| 355 | `notebook::smb` provides blocking SMB access for OneNote sections and | ||
| 356 | table-of-contents files. The core `onestore` crate remains independent of network runtimes. This is an | ||
| 357 | experimental Rust API with native interoperability evidence in the repository's | ||
| 358 | [Milestone 9](../../evidence/MILESTONE9.md). | ||
| 359 | |||
| 360 | ```no_run | ||
| 361 | use notebook::smb::{Client, Credentials}; | ||
| 362 | use std::time::Duration; | ||
| 363 | |||
| 364 | let client = Client::connect( | ||
| 365 | "server:445", | ||
| 366 | "notes", | ||
| 367 | Credentials { username: "user", password: "password", domain: "" }, | ||
| 368 | Duration::from_secs(5), | ||
| 369 | )?; | ||
| 370 | let snapshot = client.read("Personal/Video.one", 64 * 1024 * 1024)?; | ||
| 371 | let store = onestore::Store::parse(&snapshot)?; | ||
| 372 | let revisions = onestore::RevisionIndex::parse(&store)?; | ||
| 373 | let document = onestore::document::Document::parse(&revisions)?; | ||
| 374 | # Ok::<(), Box<dyn std::error::Error>>(()) | ||
| 375 | ``` | ||
| 376 | |||
| 377 | Paths are relative to the share. The read limit bounds the complete physical | ||
| 378 | snapshot. Call from a background thread outside a Tokio runtime. Use identities | ||
| 379 | from the document and the same snapshot with `Client::commit_text` or | ||
| 380 | `Client::commit_property_bytes`; their errors retain `onestore::CommitState`. | ||
| 381 | `PreparedEdit::{text,insert,format}` separate preparation from I/O: inspect the immutable image | ||
| 382 | and persist the intended revision identity before `Client::commit_prepared`. | ||
| 383 | `Client::confirm_snapshot` compares and flushes an observed image, then refreshes | ||
| 384 | its header version metadata without adding a revision. The caller must first | ||
| 385 | establish which intents that image contains and reread before another commit. | ||
| 386 | |||
| 387 | Readers use shared native guards while writers publish under native write-open | ||
| 388 | and byte-lock exclusion. Maintenance is excluded during each operation; pathname | ||
| 389 | identity is checked after acquiring the guards. Connection loss retires the | ||
| 390 | client. Reconnect for subsequent operations, and reconcile an `Unknown` edit | ||
| 391 | before retrying it. The transport does not automatically replay requests. | ||
| 392 | |||
| 393 | `Client::read_dir(path, entry_limit)` enumerates a directory, including the share | ||
| 394 | root with an empty path. It follows every response page and returns no partial | ||
| 395 | list on interruption, entry-limit overflow or close failure. Entries retain exact | ||
| 396 | Unicode names, observed sizes and MS-FSCC attributes, including directory/reparse | ||
| 397 | flags. Concurrent directory changes are not an atomic snapshot; repeated names | ||
| 398 | are rejected with `ResourceBusy`. Notebook identities come from the files, not | ||
| 399 | directory names or sizes. Missing paths, denied access and non-directory paths | ||
| 400 | have distinct I/O error kinds. | ||
| 401 | |||
| 402 | `Client::read_asset(path, byte_limit)` reads an external payload under a read-only | ||
| 403 | share handle that excludes writes and deletion. Empty files succeed; limits, | ||
| 404 | interrupted reads and failed close never return partial bytes. This payload read | ||
| 405 | does not parse a OneStore header or acquire its reader-coordination bytes. | ||
| 406 | |||
| 407 | `python3 tools/test_smb_directory.py VM OUTPUT` checks a caller-owned disposable | ||
| 408 | Linux lab VM against its filesystem listing and interrupts directory requests, | ||
| 409 | responses and close. It creates synthetic files in that VM; the caller retains | ||
| 410 | responsibility for VM teardown. The parser also has bounded-record/truncation | ||
| 411 | tests independent of the server. | ||
| 412 | |||
| 413 | Device and simulator builds link for iOS. Native acceptance uses disposable | ||
| 414 | OneNote 2010 clients and Samba; it does not establish on-device execution or | ||
| 415 | physical power-loss durability. | ||
crates/notebook/examples/cache_probe.rs created+238| ... | @@ -0,0 +1,238 @@ | ||
| 1 | use notebook::Replica; | ||
| 2 | use onestore::{ | ||
| 3 | ExGuid, Insertion, RevisionIndex, Store, TextAttribute, | ||
| 4 | document::{Document, Kind}, | ||
| 5 | }; | ||
| 6 | use std::{ | ||
| 7 | io::{self, BufRead, Write}, | ||
| 8 | path::Path, | ||
| 9 | }; | ||
| 10 | |||
| 11 | fn content(bytes: &[u8]) -> (ExGuid, ExGuid, String) { | ||
| 12 | let store = Store::parse(bytes).unwrap(); | ||
| 13 | assert!(store.checksum_mismatches.is_empty()); | ||
| 14 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 15 | index.validate_current().unwrap(); | ||
| 16 | let document = Document::parse(&index).unwrap(); | ||
| 17 | document | ||
| 18 | .spaces | ||
| 19 | .iter() | ||
| 20 | .find_map(|(sid, space)| { | ||
| 21 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 22 | revision | ||
| 23 | .nodes | ||
| 24 | .iter() | ||
| 25 | .find_map(|(oid, node)| match &node.kind { | ||
| 26 | Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())), | ||
| 27 | _ => None, | ||
| 28 | }) | ||
| 29 | }) | ||
| 30 | .unwrap() | ||
| 31 | } | ||
| 32 | |||
| 33 | fn payload(operation: u64, size: usize) -> String { | ||
| 34 | format!("{operation}:🦀{}", "x".repeat(size)) | ||
| 35 | } | ||
| 36 | |||
| 37 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 38 | let args: Vec<_> = std::env::args().collect(); | ||
| 39 | let mode = &args[1]; | ||
| 40 | let path = Path::new(&args[2]); | ||
| 41 | let operation_kind = | ||
| 42 | std::env::var("ONESTORE_CACHE_PROBE_OPERATION").unwrap_or_else(|_| "text".into()); | ||
| 43 | assert!(matches!( | ||
| 44 | operation_kind.as_str(), | ||
| 45 | "text" | "insert" | "format" | ||
| 46 | )); | ||
| 47 | let size = match std::env::var("ONESTORE_CACHE_PROBE_BYTES") { | ||
| 48 | Ok(value) => value.parse::<usize>()?, | ||
| 49 | Err(std::env::VarError::NotPresent) => 2 * 1024 * 1024, | ||
| 50 | Err(error) => return Err(error.into()), | ||
| 51 | }; | ||
| 52 | assert!(size > 0 && size <= 2 * 1024 * 1024); | ||
| 53 | let seed = std::env::var_os("ONESTORE_CACHE_PROBE_SOURCE") | ||
| 54 | .map(std::fs::read) | ||
| 55 | .transpose()?; | ||
| 56 | if mode == "init" { | ||
| 57 | let source = match seed { | ||
| 58 | Some(source) => source, | ||
| 59 | None => onestore::create_section( | ||
| 60 | "cache.one", | ||
| 61 | &if operation_kind == "format" { | ||
| 62 | payload(0, size) | ||
| 63 | } else { | ||
| 64 | "Base".into() | ||
| 65 | }, | ||
| 66 | "Fixture", | ||
| 67 | )?, | ||
| 68 | }; | ||
| 69 | Replica::create(path, &source)?; | ||
| 70 | return Ok(()); | ||
| 71 | } | ||
| 72 | let cache = Replica::open(path)?; | ||
| 73 | if mode == "read" { | ||
| 74 | let snapshot = cache.snapshot()?; | ||
| 75 | let base = cache.remote_snapshot()?; | ||
| 76 | let (sid, target, mut expected) = content(&base); | ||
| 77 | let store = Store::parse(&snapshot)?; | ||
| 78 | assert!(store.checksum_mismatches.is_empty()); | ||
| 79 | let index = RevisionIndex::parse(&store)?; | ||
| 80 | index.validate_current()?; | ||
| 81 | let document = Document::parse(&index)?; | ||
| 82 | let space = &document.spaces[&sid]; | ||
| 83 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 84 | let mut operations = Vec::new(); | ||
| 85 | let mut ids = Vec::new(); | ||
| 86 | let mut font_size = None; | ||
| 87 | for pending in cache.pending()? { | ||
| 88 | let operation = match pending.operation { | ||
| 89 | notebook::Operation::Text(edit) => { | ||
| 90 | assert_eq!(operation_kind, "text"); | ||
| 91 | assert_eq!(edit.object, target); | ||
| 92 | assert_eq!(edit.before, expected); | ||
| 93 | assert_eq!( | ||
| 94 | edit.range, | ||
| 95 | 0..u32::try_from(expected.encode_utf16().count())? | ||
| 96 | ); | ||
| 97 | let operation: u64 = edit.replacement.split_once(':').unwrap().0.parse()?; | ||
| 98 | expected = payload(operation, size); | ||
| 99 | assert_eq!(edit.replacement, expected); | ||
| 100 | operation | ||
| 101 | } | ||
| 102 | notebook::Operation::Insert(insertion) => { | ||
| 103 | assert_eq!(operation_kind, "insert"); | ||
| 104 | let Kind::RichText { text, .. } = | ||
| 105 | &revision.nodes[&insertion.text_object()].kind | ||
| 106 | else { | ||
| 107 | panic!("Missing inserted text") | ||
| 108 | }; | ||
| 109 | let operation: u64 = text.split_once(':').unwrap().0.parse()?; | ||
| 110 | assert_eq!(*text, payload(operation, size)); | ||
| 111 | assert_eq!(serde_json::to_value(&insertion)?["text"], *text); | ||
| 112 | let outline = &revision.nodes[&insertion.object()]; | ||
| 113 | assert!(matches!(outline.kind, Kind::Outline { .. })); | ||
| 114 | assert_eq!( | ||
| 115 | (outline.layout.x, outline.layout.y), | ||
| 116 | (Some(144.0), Some(operation as f32 * 72.0)) | ||
| 117 | ); | ||
| 118 | let (_, page) = document | ||
| 119 | .pages()? | ||
| 120 | .into_iter() | ||
| 121 | .find(|(space, _)| *space == sid) | ||
| 122 | .unwrap(); | ||
| 123 | assert!(revision.nodes[&page].children.contains(&insertion.object())); | ||
| 124 | operation | ||
| 125 | } | ||
| 126 | notebook::Operation::Format(edit) => { | ||
| 127 | assert_eq!(operation_kind, "format"); | ||
| 128 | assert_eq!(edit.object, target); | ||
| 129 | assert_eq!(edit.before, expected); | ||
| 130 | assert_eq!( | ||
| 131 | edit.range, | ||
| 132 | 0..u32::try_from(expected.encode_utf16().count())? | ||
| 133 | ); | ||
| 134 | let [TextAttribute::FontSize(value)] = edit.attributes.as_slice() else { | ||
| 135 | panic!("Unexpected formatting intent") | ||
| 136 | }; | ||
| 137 | assert!((7.0..=130.0).contains(value) && value.fract() == 0.0); | ||
| 138 | font_size = Some(*value); | ||
| 139 | *value as u64 - 6 | ||
| 140 | } | ||
| 141 | notebook::Operation::Split(_) | ||
| 142 | | notebook::Operation::CreatePage(_) | ||
| 143 | | notebook::Operation::Pages(_) | ||
| 144 | | notebook::Operation::Join(_) | ||
| 145 | | notebook::Operation::Outline(_) | ||
| 146 | | notebook::Operation::Tree(_) => { | ||
| 147 | panic!("Unexpected operation for this fixture") | ||
| 148 | } | ||
| 149 | }; | ||
| 150 | assert!(operations.last().is_none_or(|last| *last < operation)); | ||
| 151 | assert!(ids.last().is_none_or(|last| *last < pending.id)); | ||
| 152 | operations.push(operation); | ||
| 153 | ids.push(pending.id); | ||
| 154 | } | ||
| 155 | assert!(matches!(&revision.nodes[&target].kind,Kind::RichText{text,..} if *text==expected)); | ||
| 156 | if let Some(font_size) = font_size { | ||
| 157 | assert!( | ||
| 158 | revision | ||
| 159 | .text_runs(target)? | ||
| 160 | .iter() | ||
| 161 | .all(|run| run.format.font_size == Some(font_size)) | ||
| 162 | ); | ||
| 163 | } | ||
| 164 | if operations.is_empty() { | ||
| 165 | assert!( | ||
| 166 | snapshot == base, | ||
| 167 | "An empty local queue changed its working image" | ||
| 168 | ); | ||
| 169 | } | ||
| 170 | if let Some(output) = args.get(3) { | ||
| 171 | std::fs::write(output, &snapshot)?; | ||
| 172 | } | ||
| 173 | println!( | ||
| 174 | "{}", | ||
| 175 | serde_json::json!({"operations": operations, "ids": ids, "section_bytes": snapshot.len(), "complete_payloads": true}) | ||
| 176 | ); | ||
| 177 | return Ok(()); | ||
| 178 | } | ||
| 179 | assert_eq!(mode, "edit"); | ||
| 180 | assert!( | ||
| 181 | matches!(Replica::open(path), Err(notebook::Error::Database(error)) if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy)) | ||
| 182 | ); | ||
| 183 | println!("ready"); | ||
| 184 | io::stdout().flush()?; | ||
| 185 | let input = io::stdin(); | ||
| 186 | let mut lines = input.lock().lines(); | ||
| 187 | let instruction = lines.next().unwrap()?; | ||
| 188 | let (operation, acknowledgement) = instruction.split_once(' ').unwrap(); | ||
| 189 | let operation: u64 = operation.parse()?; | ||
| 190 | let source = cache.snapshot()?; | ||
| 191 | let (sid, oid, text) = content(&source); | ||
| 192 | let replacement = payload(operation, size); | ||
| 193 | println!("editing {operation}"); | ||
| 194 | io::stdout().flush()?; | ||
| 195 | let id = match operation_kind.as_str() { | ||
| 196 | "text" => cache.edit_text( | ||
| 197 | &source, | ||
| 198 | sid, | ||
| 199 | oid, | ||
| 200 | 0..text.encode_utf16().count().try_into()?, | ||
| 201 | &replacement, | ||
| 202 | )?, | ||
| 203 | "insert" => { | ||
| 204 | let store = Store::parse(&source)?; | ||
| 205 | let index = RevisionIndex::parse(&store)?; | ||
| 206 | let document = Document::parse(&index)?; | ||
| 207 | let (sid, page) = document.pages()?[0]; | ||
| 208 | let insertion = Insertion::outline( | ||
| 209 | page, | ||
| 210 | 144.0, | ||
| 211 | operation as f32 * 72.0, | ||
| 212 | &replacement, | ||
| 213 | "Fixture", | ||
| 214 | )?; | ||
| 215 | cache.insert(&source, sid, &insertion)? | ||
| 216 | } | ||
| 217 | "format" => { | ||
| 218 | assert!((1..=124).contains(&operation)); | ||
| 219 | cache.format( | ||
| 220 | &source, | ||
| 221 | sid, | ||
| 222 | oid, | ||
| 223 | 0..text.encode_utf16().count().try_into()?, | ||
| 224 | &[TextAttribute::FontSize(6.0 + operation as f32)], | ||
| 225 | )? | ||
| 226 | } | ||
| 227 | _ => unreachable!(), | ||
| 228 | } | ||
| 229 | .unwrap(); | ||
| 230 | if acknowledgement == "unack" { | ||
| 231 | println!("durable {operation} {id}"); | ||
| 232 | } else { | ||
| 233 | println!("ack {operation} {id}"); | ||
| 234 | } | ||
| 235 | io::stdout().flush()?; | ||
| 236 | lines.next().transpose()?; | ||
| 237 | Ok(()) | ||
| 238 | } | ||
crates/notebook/examples/discover.rs created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | use notebook::discover::{Limits, Local, discover}; | ||
| 2 | |||
| 3 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 4 | let args: Vec<_> = std::env::args_os().skip(1).collect(); | ||
| 5 | let limits = Limits { | ||
| 6 | entries: 100_000, | ||
| 7 | bytes_per_file: 256 * 1024 * 1024, | ||
| 8 | depth: 64, | ||
| 9 | }; | ||
| 10 | let catalog = match args.as_slice() { | ||
| 11 | [root] => discover(&mut Local::open(root)?, limits)?, | ||
| 12 | #[cfg(feature = "smb")] | ||
| 13 | [flag, address, share, root] if flag == "--smb" => { | ||
| 14 | use notebook::smb::{Client, Credentials}; | ||
| 15 | let username = std::env::var("ONESTORE_SMB_USERNAME").unwrap_or_default(); | ||
| 16 | let password = std::env::var("ONESTORE_SMB_PASSWORD").unwrap_or_default(); | ||
| 17 | let domain = std::env::var("ONESTORE_SMB_DOMAIN").unwrap_or_default(); | ||
| 18 | let client = Client::connect( | ||
| 19 | address.to_str().ok_or("Use a UTF-8 server address")?, | ||
| 20 | share.to_str().ok_or("Use a UTF-8 share name")?, | ||
| 21 | Credentials { | ||
| 22 | username: &username, | ||
| 23 | password: &password, | ||
| 24 | domain: &domain, | ||
| 25 | }, | ||
| 26 | std::time::Duration::from_secs(5), | ||
| 27 | )?; | ||
| 28 | discover( | ||
| 29 | &mut notebook::discover::Smb::new( | ||
| 30 | &client, | ||
| 31 | root.to_str().ok_or("Use a UTF-8 notebook path")?, | ||
| 32 | )?, | ||
| 33 | limits, | ||
| 34 | )? | ||
| 35 | } | ||
| 36 | _ => return Err("Provide ROOT, or --smb ADDRESS SHARE ROOT with the smb feature".into()), | ||
| 37 | }; | ||
| 38 | serde_json::to_writer_pretty(std::io::stdout().lock(), &catalog)?; | ||
| 39 | Ok(()) | ||
| 40 | } | ||
crates/notebook/examples/document.rs created+170| ... | @@ -0,0 +1,170 @@ | ||
| 1 | use onestore::{ | ||
| 2 | FileDataReference, RevisionIndex, Store, | ||
| 3 | document::{Document, Kind}, | ||
| 4 | }; | ||
| 5 | use std::{ | ||
| 6 | borrow::Cow, | ||
| 7 | collections::BTreeSet, | ||
| 8 | env, fs, | ||
| 9 | io::{self, BufWriter, Write}, | ||
| 10 | path::{Path, PathBuf}, | ||
| 11 | }; | ||
| 12 | |||
| 13 | fn write_json( | ||
| 14 | path: impl AsRef<Path>, | ||
| 15 | value: &impl serde::Serialize, | ||
| 16 | ) -> Result<(), Box<dyn std::error::Error>> { | ||
| 17 | let mut output = BufWriter::new(fs::File::create(path)?); | ||
| 18 | serde_json::to_writer(&mut output, value)?; | ||
| 19 | output.flush()?; | ||
| 20 | Ok(()) | ||
| 21 | } | ||
| 22 | |||
| 23 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 24 | let mut args = env::args_os().skip(1); | ||
| 25 | let path = args.next().ok_or("Provide a .one or .onetoc2 file.")?; | ||
| 26 | let next = args.next(); | ||
| 27 | let (destination, option) = if next.as_deref() == Some(std::ffi::OsStr::new("--password-file")) | ||
| 28 | { | ||
| 29 | (None, next) | ||
| 30 | } else { | ||
| 31 | (next.map(PathBuf::from), args.next()) | ||
| 32 | }; | ||
| 33 | let password_file = match (option, args.next(), args.next()) { | ||
| 34 | (None, None, None) => None, | ||
| 35 | (Some(flag), Some(path), None) if flag == "--password-file" => Some(PathBuf::from(path)), | ||
| 36 | _ => return Err("Provide a source file, an optional new export directory, and optionally --password-file PATH.".into()), | ||
| 37 | }; | ||
| 38 | let source_path = PathBuf::from(path); | ||
| 39 | let bytes = onestore::read_file(&source_path)?; | ||
| 40 | let store = Store::parse(&bytes)?; | ||
| 41 | let index = RevisionIndex::parse(&store)?; | ||
| 42 | #[cfg(feature = "protected")] | ||
| 43 | let unlocked = if let Some(path) = &password_file { | ||
| 44 | use std::io::Read; | ||
| 45 | let mut password = zeroize::Zeroizing::new(String::new()); | ||
| 46 | fs::File::open(path)? | ||
| 47 | .take(65537) | ||
| 48 | .read_to_string(&mut password)?; | ||
| 49 | if password.len() > 65536 { | ||
| 50 | return Err("The password file exceeds 64 KiB.".into()); | ||
| 51 | } | ||
| 52 | Some(onestore::protected::UnlockedSection::open( | ||
| 53 | &index, | ||
| 54 | &password, | ||
| 55 | Default::default(), | ||
| 56 | )?) | ||
| 57 | } else { | ||
| 58 | None | ||
| 59 | }; | ||
| 60 | #[cfg(not(feature = "protected"))] | ||
| 61 | if password_file.is_some() { | ||
| 62 | return Err( | ||
| 63 | "Build this exporter with the protected feature to open a password-protected section." | ||
| 64 | .into(), | ||
| 65 | ); | ||
| 66 | } | ||
| 67 | #[cfg(feature = "protected")] | ||
| 68 | let document = match &unlocked { | ||
| 69 | Some(section) => section.document()?, | ||
| 70 | None => Document::parse(&index)?, | ||
| 71 | }; | ||
| 72 | #[cfg(not(feature = "protected"))] | ||
| 73 | let document = Document::parse(&index)?; | ||
| 74 | if let Some(destination) = destination { | ||
| 75 | #[cfg(unix)] | ||
| 76 | { | ||
| 77 | use std::os::unix::fs::DirBuilderExt; | ||
| 78 | fs::DirBuilder::new() | ||
| 79 | .mode(if password_file.is_some() { | ||
| 80 | 0o700 | ||
| 81 | } else { | ||
| 82 | 0o777 | ||
| 83 | }) | ||
| 84 | .create(&destination)?; | ||
| 85 | } | ||
| 86 | #[cfg(not(unix))] | ||
| 87 | fs::create_dir(&destination)?; | ||
| 88 | fs::create_dir(destination.join("assets"))?; | ||
| 89 | let parent = source_path | ||
| 90 | .parent() | ||
| 91 | .filter(|parent| !parent.as_os_str().is_empty()) | ||
| 92 | .unwrap_or_else(|| Path::new(".")); | ||
| 93 | let mut source = notebook::discover::Local::open(parent)?; | ||
| 94 | let section = source_path | ||
| 95 | .file_name() | ||
| 96 | .and_then(|name| name.to_str()) | ||
| 97 | .ok_or("Use a UTF-8 section filename")?; | ||
| 98 | let mut assets = Vec::new(); | ||
| 99 | let mut seen = BTreeSet::new(); | ||
| 100 | for node in document | ||
| 101 | .spaces | ||
| 102 | .values() | ||
| 103 | .flat_map(|s| s.revisions.values()) | ||
| 104 | .flat_map(|r| r.nodes.values()) | ||
| 105 | { | ||
| 106 | if let Kind::File { | ||
| 107 | reference, payload, .. | ||
| 108 | } = &node.kind | ||
| 109 | && seen.insert(serde_json::to_string(reference)?) | ||
| 110 | { | ||
| 111 | let data = match reference { | ||
| 112 | FileDataReference::Internal(_) => { | ||
| 113 | Cow::Borrowed(payload.ok_or("Missing embedded file data")?) | ||
| 114 | } | ||
| 115 | FileDataReference::External(filename) => { | ||
| 116 | if password_file.is_some() { | ||
| 117 | assets.push(serde_json::json!({"reference":reference,"path":null,"error":{"kind":"Unsupported","message":"Protected external payload export is not supported"}})); | ||
| 118 | continue; | ||
| 119 | } | ||
| 120 | match notebook::discover::read_external_asset( | ||
| 121 | &mut source, | ||
| 122 | section, | ||
| 123 | filename, | ||
| 124 | 256 * 1024 * 1024, | ||
| 125 | ) { | ||
| 126 | Ok(bytes) => Cow::Owned(bytes), | ||
| 127 | Err(error) => { | ||
| 128 | let kind = match &error { | ||
| 129 | notebook::discover::Error::Io { error, .. } => { | ||
| 130 | format!("{:?}", error.kind()) | ||
| 131 | } | ||
| 132 | _ => "InvalidData".into(), | ||
| 133 | }; | ||
| 134 | assets.push(serde_json::json!({"reference":reference,"path":null,"error":{"kind":kind,"message":error.to_string()}})); | ||
| 135 | continue; | ||
| 136 | } | ||
| 137 | } | ||
| 138 | } | ||
| 139 | FileDataReference::Invalid => { | ||
| 140 | assets.push(serde_json::json!({"reference":reference,"path":null,"error":{"kind":"InvalidData","message":"The document marks this payload as unavailable"}})); | ||
| 141 | continue; | ||
| 142 | } | ||
| 143 | }; | ||
| 144 | let path = format!("assets/{}.bin", assets.len()); | ||
| 145 | fs::write(destination.join(&path), data)?; | ||
| 146 | assets.push(serde_json::json!({"reference":reference,"path":path})); | ||
| 147 | } | ||
| 148 | } | ||
| 149 | write_json(destination.join("assets.json"), &assets)?; | ||
| 150 | let mut text = std::collections::BTreeMap::new(); | ||
| 151 | for (sid, space) in &document.spaces { | ||
| 152 | let mut revisions = std::collections::BTreeMap::new(); | ||
| 153 | for (rid, revision) in &space.revisions { | ||
| 154 | let mut objects = std::collections::BTreeMap::new(); | ||
| 155 | for (oid, node) in &revision.nodes { | ||
| 156 | if matches!(node.kind, Kind::RichText { .. }) { | ||
| 157 | objects.insert(*oid, revision.text_runs(*oid)?); | ||
| 158 | } | ||
| 159 | } | ||
| 160 | revisions.insert(*rid, objects); | ||
| 161 | } | ||
| 162 | text.insert(*sid, revisions); | ||
| 163 | } | ||
| 164 | write_json(destination.join("text.json"), &text)?; | ||
| 165 | write_json(destination.join("document.json"), &document)?; | ||
| 166 | } else { | ||
| 167 | serde_json::to_writer(io::stdout().lock(), &document)?; | ||
| 168 | } | ||
| 169 | Ok(()) | ||
| 170 | } | ||
crates/notebook/examples/externalize_fixture.rs created+101| ... | @@ -0,0 +1,101 @@ | ||
| 1 | //! Builds a new external-payload test fixture from a native capture with reserved fragment space. | ||
| 2 | use onestore::{FileDataReference, RevisionIndex, Store}; | ||
| 3 | use std::{fs, path::PathBuf}; | ||
| 4 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 5 | let args: Vec<_> = std::env::args_os().skip(1).collect(); | ||
| 6 | if args.len() != 2 { | ||
| 7 | return Err("Provide a native source section and a new fixture directory".into()); | ||
| 8 | } | ||
| 9 | let source = fs::read(&args[0])?; | ||
| 10 | let destination = PathBuf::from(&args[1]); | ||
| 11 | fs::create_dir(&destination)?; | ||
| 12 | fs::create_dir(destination.join("synthetic_onefiles"))?; | ||
| 13 | let store = Store::parse(&source)?; | ||
| 14 | assert!(store.checksum_mismatches.is_empty()); | ||
| 15 | RevisionIndex::parse(&store)?.validate_current()?; | ||
| 16 | let mut output = source.clone(); | ||
| 17 | let mut changed = 0; | ||
| 18 | for list in store.lists.values() { | ||
| 19 | for chunk in &list.fragments { | ||
| 20 | let start = chunk.offset as usize; | ||
| 21 | let end = start + chunk.length as usize - 20; | ||
| 22 | let mut body = Vec::new(); | ||
| 23 | let mut touched = false; | ||
| 24 | for node in list | ||
| 25 | .nodes | ||
| 26 | .iter() | ||
| 27 | .filter(|node| node.offset >= start + 16 && node.offset < end) | ||
| 28 | { | ||
| 29 | let raw = u32::from_le_bytes(source[node.offset..node.offset + 4].try_into()?); | ||
| 30 | let size = ((raw >> 10) & 0x1fff) as usize; | ||
| 31 | let mut encoded = source[node.offset..node.offset + size].to_vec(); | ||
| 32 | if matches!(node.id, 0x72 | 0x73) { | ||
| 33 | let offset = 4 + 4 + 4 + if node.id == 0x72 { 1 } else { 4 }; | ||
| 34 | let count = | ||
| 35 | u32::from_le_bytes(encoded[offset..offset + 4].try_into()?) as usize; | ||
| 36 | let text = String::from_utf16( | ||
| 37 | &encoded[offset + 4..offset + 4 + count * 2] | ||
| 38 | .chunks_exact(2) | ||
| 39 | .map(|v| u16::from_le_bytes(v.try_into().unwrap())) | ||
| 40 | .collect::<Vec<_>>(), | ||
| 41 | )?; | ||
| 42 | if let FileDataReference::Internal(guid) = text.parse()? { | ||
| 43 | let filename = format!( | ||
| 44 | "{}.onebin", | ||
| 45 | text.strip_prefix("<ifndf>{") | ||
| 46 | .unwrap() | ||
| 47 | .strip_suffix('}') | ||
| 48 | .unwrap() | ||
| 49 | ); | ||
| 50 | let payload = store.file_data(guid)?; | ||
| 51 | let path = destination.join("synthetic_onefiles").join(&filename); | ||
| 52 | if path.exists() { | ||
| 53 | assert_eq!(fs::read(&path)?, payload); | ||
| 54 | } else { | ||
| 55 | fs::write(path, payload)?; | ||
| 56 | } | ||
| 57 | let reference = format!("<file>{filename}"); | ||
| 58 | let data: Vec<_> = reference | ||
| 59 | .encode_utf16() | ||
| 60 | .flat_map(u16::to_le_bytes) | ||
| 61 | .collect(); | ||
| 62 | encoded.splice(offset + 4..offset + 4 + count * 2, data.iter().copied()); | ||
| 63 | encoded[offset..offset + 4] | ||
| 64 | .copy_from_slice(&(data.len() as u32 / 2).to_le_bytes()); | ||
| 65 | assert!(encoded.len() <= 0x1fff); | ||
| 66 | let header = (raw & !(0x1fff << 10)) | ((encoded.len() as u32) << 10); | ||
| 67 | encoded[..4].copy_from_slice(&header.to_le_bytes()); | ||
| 68 | changed += 1; | ||
| 69 | touched = true; | ||
| 70 | } | ||
| 71 | } | ||
| 72 | body.extend(encoded); | ||
| 73 | } | ||
| 74 | if touched { | ||
| 75 | assert!( | ||
| 76 | body.len() + 4 <= end - start - 16, | ||
| 77 | "Native fragment has insufficient reserved space" | ||
| 78 | ); | ||
| 79 | body.extend_from_slice(&(0xff_u32 | (4 << 10)).to_le_bytes()); | ||
| 80 | output[start + 16..end].fill(0); | ||
| 81 | output[start + 16..start + 16 + body.len()].copy_from_slice(&body); | ||
| 82 | } | ||
| 83 | } | ||
| 84 | } | ||
| 85 | assert!(changed > 0); | ||
| 86 | let converted = Store::parse(&output)?; | ||
| 87 | assert!(converted.checksum_mismatches.is_empty()); | ||
| 88 | RevisionIndex::parse(&converted)?.validate_current()?; | ||
| 89 | fs::write(destination.join("synthetic.one"), &output)?; | ||
| 90 | fs::write( | ||
| 91 | destination.join("Open Notebook.onetoc2"), | ||
| 92 | onestore::create_table_of_contents( | ||
| 93 | "Open Notebook.onetoc2", | ||
| 94 | &[("synthetic.one", converted.header.file_id)], | ||
| 95 | )?, | ||
| 96 | )?; | ||
| 97 | println!( | ||
| 98 | "Converted {changed} file-data declarations without changing payload identities or bytes" | ||
| 99 | ); | ||
| 100 | Ok(()) | ||
| 101 | } | ||
crates/notebook/examples/recovery_probe.rs created+160| ... | @@ -0,0 +1,160 @@ | ||
| 1 | mod support { | ||
| 2 | pub mod view; | ||
| 3 | } | ||
| 4 | use support::view::view; | ||
| 5 | |||
| 6 | use notebook::{EditStatus, Remote, Replica}; | ||
| 7 | use onestore::{CommitError, CommitIo, PreparedEdit}; | ||
| 8 | use serde_json::json; | ||
| 9 | use std::{ | ||
| 10 | env, | ||
| 11 | fs::{self, File, OpenOptions}, | ||
| 12 | io::{self, Write}, | ||
| 13 | os::unix::fs::FileExt, | ||
| 14 | path::Path, | ||
| 15 | }; | ||
| 16 | |||
| 17 | fn phase(name: &str) { | ||
| 18 | println!("{}", json!({"event":"phase", "name":name})); | ||
| 19 | io::stdout().flush().unwrap(); | ||
| 20 | if env::var("ONESTORE_RECOVERY_PAUSE").ok().as_deref() == Some(name) { | ||
| 21 | let mut line = String::new(); | ||
| 22 | assert!( | ||
| 23 | io::stdin().read_line(&mut line).unwrap() > 0, | ||
| 24 | "Controller closed a paused operation" | ||
| 25 | ); | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | struct Disk { | ||
| 30 | file: File, | ||
| 31 | writes: usize, | ||
| 32 | flushes: usize, | ||
| 33 | } | ||
| 34 | |||
| 35 | impl CommitIo for Disk { | ||
| 36 | fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> { | ||
| 37 | self.file.read_at(output, offset) | ||
| 38 | } | ||
| 39 | fn write_at(&mut self, offset: u64, data: &[u8]) -> io::Result<usize> { | ||
| 40 | self.writes += 1; | ||
| 41 | println!( | ||
| 42 | "{}", | ||
| 43 | json!({"event":"write", "number":self.writes, "offset":offset, "bytes":data.len()}) | ||
| 44 | ); | ||
| 45 | phase(&format!("write-{}-before", self.writes)); | ||
| 46 | let result = self.file.write_at(data, offset); | ||
| 47 | phase(&format!("write-{}-after", self.writes)); | ||
| 48 | result | ||
| 49 | } | ||
| 50 | fn flush(&mut self) -> io::Result<()> { | ||
| 51 | self.flushes += 1; | ||
| 52 | phase(&format!("flush-{}-before", self.flushes)); | ||
| 53 | let result = self.file.sync_all(); | ||
| 54 | phase(&format!("flush-{}-after", self.flushes)); | ||
| 55 | result | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | impl Remote for Disk { | ||
| 60 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 61 | phase("read-before"); | ||
| 62 | let result = onestore::read_snapshot( | ||
| 63 | |offset, output| self.file.read_at(output, offset), | ||
| 64 | 256 * 1024 * 1024, | ||
| 65 | ) | ||
| 66 | .and_then(|snapshot| snapshot.ok_or_else(|| io::ErrorKind::WouldBlock.into())); | ||
| 67 | phase("read-after"); | ||
| 68 | result | ||
| 69 | } | ||
| 70 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 71 | phase("publish-before"); | ||
| 72 | let result = edit.commit(self); | ||
| 73 | phase("publish-after"); | ||
| 74 | result | ||
| 75 | } | ||
| 76 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 77 | phase("confirm-before"); | ||
| 78 | let result = onestore::confirm_snapshot(self, snapshot); | ||
| 79 | phase("confirm-after"); | ||
| 80 | result | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | fn report(cache: &Replica, root: &Path) -> Result<(), Box<dyn std::error::Error>> { | ||
| 85 | let local = view(&cache.snapshot()?)?; | ||
| 86 | let remote = view(&fs::read(root.join("remote.one"))?)?; | ||
| 87 | let (status, revision) = match cache.status(1)? { | ||
| 88 | Some(EditStatus::Pending) => ("pending", None), | ||
| 89 | Some(EditStatus::AwaitingConfirmation { revision }) => { | ||
| 90 | ("uncertain", Some(revision.to_string())) | ||
| 91 | } | ||
| 92 | Some(EditStatus::Published { revision }) => ("published", Some(revision.to_string())), | ||
| 93 | Some(EditStatus::Conflict(_)) => ("conflict", None), | ||
| 94 | None => ("missing", None), | ||
| 95 | }; | ||
| 96 | println!( | ||
| 97 | "{}", | ||
| 98 | json!({"event":"state", "status":status, "revision":revision, | ||
| 99 | "local_text":local.text, "remote_text":remote.text, "remote_revision":remote.revision.to_string(), | ||
| 100 | "pending":cache.pending()?.iter().map(|pending|match &pending.operation { | ||
| 101 | notebook::Operation::Text(edit) => json!({"id":pending.id,"before":edit.before,"replacement":edit.replacement,"range":[edit.range.start,edit.range.end]}), | ||
| 102 | operation => json!({"id":pending.id,"operation":operation}), | ||
| 103 | }).collect::<Vec<_>>() }) | ||
| 104 | ); | ||
| 105 | Ok(()) | ||
| 106 | } | ||
| 107 | |||
| 108 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 109 | let args: Vec<_> = env::args().skip(1).collect(); | ||
| 110 | let mode = args | ||
| 111 | .first() | ||
| 112 | .ok_or("Expected init|inspect|sync DIRECTORY [SOURCE]")?; | ||
| 113 | let root = Path::new(args.get(1).ok_or("Missing owned directory")?); | ||
| 114 | if mode == "init" && args.len() == 3 { | ||
| 115 | let source = fs::read(&args[2])?; | ||
| 116 | let target = view(&source)?; | ||
| 117 | fs::create_dir(root)?; | ||
| 118 | let mut remote = OpenOptions::new() | ||
| 119 | .write(true) | ||
| 120 | .create_new(true) | ||
| 121 | .open(root.join("remote.one"))?; | ||
| 122 | remote.write_all(&source)?; | ||
| 123 | remote.sync_all()?; | ||
| 124 | drop(remote); | ||
| 125 | let cache = Replica::create(root.join("cache.sqlite"), &source)?; | ||
| 126 | let at = u32::try_from(target.text.encode_utf16().count())?; | ||
| 127 | assert_eq!( | ||
| 128 | cache.edit_text( | ||
| 129 | &source, | ||
| 130 | target.space, | ||
| 131 | target.object, | ||
| 132 | at..at, | ||
| 133 | " [offline-recovery]" | ||
| 134 | )?, | ||
| 135 | Some(1) | ||
| 136 | ); | ||
| 137 | phase("local-after"); | ||
| 138 | report(&cache, root)?; | ||
| 139 | } else if args.len() == 2 && ["inspect", "sync"].contains(&mode.as_str()) { | ||
| 140 | let cache = Replica::open(root.join("cache.sqlite"))?; | ||
| 141 | if mode == "sync" { | ||
| 142 | let mut disk = Disk { | ||
| 143 | file: OpenOptions::new() | ||
| 144 | .read(true) | ||
| 145 | .write(true) | ||
| 146 | .open(root.join("remote.one"))?, | ||
| 147 | writes: 0, | ||
| 148 | flushes: 0, | ||
| 149 | }; | ||
| 150 | phase("sync-before"); | ||
| 151 | let result = cache.sync_once(&mut disk); | ||
| 152 | phase("sync-after"); | ||
| 153 | result?; | ||
| 154 | } | ||
| 155 | report(&cache, root)?; | ||
| 156 | } else { | ||
| 157 | return Err("Expected init|inspect|sync DIRECTORY [SOURCE]".into()); | ||
| 158 | } | ||
| 159 | Ok(()) | ||
| 160 | } | ||
crates/notebook/examples/smb_concurrent_client.rs created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | #[path = "../../onestore/examples/support/concurrent.rs"] | ||
| 2 | mod concurrent; | ||
| 3 | |||
| 4 | use notebook::smb::{Client, Credentials}; | ||
| 5 | use std::{env, time::Duration}; | ||
| 6 | |||
| 7 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 8 | let client = Client::connect( | ||
| 9 | &env::var("ONESTORE_SMB_LAB")?, | ||
| 10 | &env::var("ONESTORE_SMB_SHARE")?, | ||
| 11 | Credentials::default(), | ||
| 12 | Duration::from_secs(10), | ||
| 13 | )?; | ||
| 14 | let args: Vec<_> = env::args().skip(1).collect(); | ||
| 15 | concurrent::run( | ||
| 16 | &args, | ||
| 17 | |path| client.read(path, 256 * 1024 * 1024), | ||
| 18 | |path, source, space, object, range, replacement| { | ||
| 19 | client.commit_text(path, source, space, object, range, replacement) | ||
| 20 | }, | ||
| 21 | ) | ||
| 22 | } | ||
crates/notebook/examples/smb_offline_client.rs created+863| ... | @@ -0,0 +1,863 @@ | ||
| 1 | #[path = "../../onestore/examples/support/concurrent.rs"] | ||
| 2 | mod concurrent; | ||
| 3 | |||
| 4 | use notebook::smb::{Client, Credentials}; | ||
| 5 | use notebook::{EditStatus, Error, Remote, Replica, SmbRemote}; | ||
| 6 | use onestore::{ | ||
| 7 | CommitError, CommitState, ExGuid, Insertion, ParagraphJoin, ParagraphSplit, PreparedEdit, | ||
| 8 | RevisionIndex, Store, TextAttribute, TreeEdit, document::Document, | ||
| 9 | }; | ||
| 10 | use serde_json::json; | ||
| 11 | use std::{ | ||
| 12 | env, | ||
| 13 | io::{self, Write}, | ||
| 14 | path::{Path, PathBuf}, | ||
| 15 | sync::Arc, | ||
| 16 | thread, | ||
| 17 | time::{Duration, Instant, SystemTime, UNIX_EPOCH}, | ||
| 18 | }; | ||
| 19 | |||
| 20 | mod support { | ||
| 21 | pub mod view; | ||
| 22 | } | ||
| 23 | use concurrent::{DocumentView, document_view}; | ||
| 24 | use support::view::view; | ||
| 25 | |||
| 26 | impl DocumentView { | ||
| 27 | fn changes(&self, before: &Self) -> Self { | ||
| 28 | let difference = | ||
| 29 | |old: &serde_json::Map<String, serde_json::Value>, | ||
| 30 | new: &serde_json::Map<String, serde_json::Value>| { | ||
| 31 | old.keys() | ||
| 32 | .chain(new.keys()) | ||
| 33 | .filter(|id| old.get(*id) != new.get(*id)) | ||
| 34 | .map(|id| (id.clone(), new.get(id).cloned().unwrap_or_default())) | ||
| 35 | .collect() | ||
| 36 | }; | ||
| 37 | Self { | ||
| 38 | texts: difference(&before.texts, &self.texts), | ||
| 39 | graph: difference(&before.graph, &self.graph), | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | const DOCUMENT_OPERATIONS: [&str; 10] = [ | ||
| 45 | "insert", | ||
| 46 | "format", | ||
| 47 | "text", | ||
| 48 | "split", | ||
| 49 | "right_text", | ||
| 50 | "nest", | ||
| 51 | "unnest", | ||
| 52 | "join", | ||
| 53 | "tail_split", | ||
| 54 | "delete", | ||
| 55 | ]; | ||
| 56 | |||
| 57 | fn now() -> u128 { | ||
| 58 | SystemTime::now() | ||
| 59 | .duration_since(UNIX_EPOCH) | ||
| 60 | .unwrap() | ||
| 61 | .as_micros() | ||
| 62 | } | ||
| 63 | |||
| 64 | #[derive(Clone)] | ||
| 65 | enum Pause { | ||
| 66 | Outage(PathBuf), | ||
| 67 | FormatReply(PathBuf), | ||
| 68 | } | ||
| 69 | |||
| 70 | struct Traced<R> { | ||
| 71 | remote: R, | ||
| 72 | before: Option<(String, Option<DocumentView>)>, | ||
| 73 | pause: Option<Pause>, | ||
| 74 | documents: bool, | ||
| 75 | } | ||
| 76 | |||
| 77 | impl<R: Remote> Remote for Traced<R> { | ||
| 78 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 79 | let started = now(); | ||
| 80 | let bytes = self.remote.read()?; | ||
| 81 | let observed = view(&bytes).map_err(|error| io::Error::other(error.to_string()))?; | ||
| 82 | let documents = if self.documents { | ||
| 83 | Some(document_view(&bytes).map_err(io::Error::other)?) | ||
| 84 | } else { | ||
| 85 | None | ||
| 86 | }; | ||
| 87 | println!( | ||
| 88 | "{}", | ||
| 89 | json!({"event":"read", "started_us":started, "finished_us":now(), "text":observed.text, "documents":documents.as_ref().map(|view| &view.texts), "document_graph":documents.as_ref().map(|view| &view.graph)}) | ||
| 90 | ); | ||
| 91 | self.before = Some((observed.text, documents)); | ||
| 92 | Ok(bytes) | ||
| 93 | } | ||
| 94 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 95 | let after = view(edit.as_bytes()).map_err(|error| CommitError { | ||
| 96 | state: CommitState::NotCommitted, | ||
| 97 | error: io::Error::other(error.to_string()), | ||
| 98 | })?; | ||
| 99 | let documents = if self.documents { | ||
| 100 | Some(document_view(edit.as_bytes()).map_err(|error| CommitError { | ||
| 101 | state: CommitState::NotCommitted, | ||
| 102 | error: io::Error::other(error), | ||
| 103 | })?) | ||
| 104 | } else { | ||
| 105 | None | ||
| 106 | }; | ||
| 107 | let changes = if let Some(after) = &documents { | ||
| 108 | let before = self | ||
| 109 | .before | ||
| 110 | .as_ref() | ||
| 111 | .and_then(|(_, documents)| documents.as_ref()) | ||
| 112 | .ok_or_else(|| CommitError { | ||
| 113 | state: CommitState::NotCommitted, | ||
| 114 | error: io::Error::other("Document publication has no observed source"), | ||
| 115 | })?; | ||
| 116 | Some(after.changes(before)) | ||
| 117 | } else { | ||
| 118 | None | ||
| 119 | }; | ||
| 120 | let pause = match &self.pause { | ||
| 121 | Some(Pause::Outage(marker)) => Some(( | ||
| 122 | marker, | ||
| 123 | marker.parent().unwrap().join("offline-outage-resumed"), | ||
| 124 | "outage", | ||
| 125 | )), | ||
| 126 | Some(Pause::FormatReply(marker)) | ||
| 127 | if changes.as_ref().is_some_and(|changes| { | ||
| 128 | changes.texts.len() == 1 | ||
| 129 | && self | ||
| 130 | .before | ||
| 131 | .as_ref() | ||
| 132 | .and_then(|(_, before)| before.as_ref()) | ||
| 133 | .is_some_and(|before| { | ||
| 134 | changes.texts.keys().all(|id| before.texts.contains_key(id)) | ||
| 135 | }) | ||
| 136 | }) => | ||
| 137 | { | ||
| 138 | Some((marker, marker.with_extension("resume"), "format")) | ||
| 139 | } | ||
| 140 | _ => None, | ||
| 141 | }; | ||
| 142 | if let Some((marker, resumed, kind)) = pause | ||
| 143 | && !resumed.exists() | ||
| 144 | { | ||
| 145 | std::fs::write(marker, after.revision.to_string()).map_err(|error| CommitError { | ||
| 146 | state: CommitState::NotCommitted, | ||
| 147 | error, | ||
| 148 | })?; | ||
| 149 | println!( | ||
| 150 | "{}", | ||
| 151 | json!({"event":"publication_paused", "revision":after.revision.to_string(), "kind":kind, "at_us":now()}) | ||
| 152 | ); | ||
| 153 | let deadline = Instant::now() + Duration::from_secs(120); | ||
| 154 | while !resumed.exists() { | ||
| 155 | if Instant::now() >= deadline { | ||
| 156 | return Err(CommitError { | ||
| 157 | state: CommitState::NotCommitted, | ||
| 158 | error: io::Error::new( | ||
| 159 | io::ErrorKind::TimedOut, | ||
| 160 | "Offline publication barrier timed out", | ||
| 161 | ), | ||
| 162 | }); | ||
| 163 | } | ||
| 164 | thread::sleep(Duration::from_millis(10)); | ||
| 165 | } | ||
| 166 | } | ||
| 167 | let started = now(); | ||
| 168 | let result = self.remote.publish(edit); | ||
| 169 | let finished = now(); | ||
| 170 | println!( | ||
| 171 | "{}", | ||
| 172 | json!({"event":"remote_attempt", "started_us":started, "finished_us":finished, | ||
| 173 | "revision":after.revision.to_string(), "space":after.space.to_string(), "object":after.object.to_string(), "before":self.before.as_ref().map(|(text,_)|text), "after":after.text, | ||
| 174 | "state":format!("{:?}", result.as_ref().map_or_else(|error| error.state, |_| CommitState::Committed)), | ||
| 175 | "documents":documents.as_ref().map(|view| &view.texts), "document_graph":documents.as_ref().map(|view| &view.graph), | ||
| 176 | "document_changes":changes.as_ref().map(|view| &view.texts), "document_graph_changes":changes.as_ref().map(|view| &view.graph)}) | ||
| 177 | ); | ||
| 178 | if result | ||
| 179 | .as_ref() | ||
| 180 | .is_err_and(|error| error.state == CommitState::Unknown) | ||
| 181 | && let Some(Pause::FormatReply(marker)) = &self.pause | ||
| 182 | && marker.with_extension("isolate").exists() | ||
| 183 | { | ||
| 184 | std::fs::write(marker.with_extension("isolate"), serde_json::to_vec(&json!({"space":after.space.to_string(), "revision":after.revision.to_string(), "after_us":finished})).unwrap()) | ||
| 185 | .map_err(|error| CommitError { state:CommitState::Unknown, error })?; | ||
| 186 | println!( | ||
| 187 | "{}", | ||
| 188 | json!({"event":"confirmation_paused", "revision":after.revision.to_string(), "at_us":now()}) | ||
| 189 | ); | ||
| 190 | let deadline = Instant::now() + Duration::from_secs(120); | ||
| 191 | while !marker.with_extension("confirmation-resume").exists() { | ||
| 192 | if Instant::now() >= deadline { | ||
| 193 | return Err(CommitError { | ||
| 194 | state: CommitState::Unknown, | ||
| 195 | error: io::Error::new( | ||
| 196 | io::ErrorKind::TimedOut, | ||
| 197 | "Offline confirmation barrier timed out", | ||
| 198 | ), | ||
| 199 | }); | ||
| 200 | } | ||
| 201 | thread::sleep(Duration::from_millis(10)); | ||
| 202 | } | ||
| 203 | } | ||
| 204 | result | ||
| 205 | } | ||
| 206 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 207 | let captured = (|| -> Result<_, Box<dyn std::error::Error>> { | ||
| 208 | let store = Store::parse(snapshot)?; | ||
| 209 | let index = RevisionIndex::parse(&store)?; | ||
| 210 | let revisions = index | ||
| 211 | .spaces | ||
| 212 | .iter() | ||
| 213 | .map(|(id, space)| { | ||
| 214 | ( | ||
| 215 | id.to_string(), | ||
| 216 | space | ||
| 217 | .revisions | ||
| 218 | .keys() | ||
| 219 | .map(ToString::to_string) | ||
| 220 | .collect::<Vec<_>>(), | ||
| 221 | ) | ||
| 222 | }) | ||
| 223 | .collect::<std::collections::BTreeMap<_, _>>(); | ||
| 224 | let current = index | ||
| 225 | .spaces | ||
| 226 | .iter() | ||
| 227 | .filter_map(|(id, space)| { | ||
| 228 | space | ||
| 229 | .labels | ||
| 230 | .get(&(ExGuid::default(), 1)) | ||
| 231 | .map(|revision| (id.to_string(), revision.to_string())) | ||
| 232 | }) | ||
| 233 | .collect::<std::collections::BTreeMap<_, _>>(); | ||
| 234 | let path = env::var_os("ONESTORE_OFFLINE_CONFIRM_DIR").map(|directory| { | ||
| 235 | PathBuf::from(directory).join(format!("{}-{}.one", std::process::id(), now())) | ||
| 236 | }); | ||
| 237 | if let Some(path) = &path { | ||
| 238 | std::fs::OpenOptions::new() | ||
| 239 | .write(true) | ||
| 240 | .create_new(true) | ||
| 241 | .open(path)? | ||
| 242 | .write_all(snapshot)?; | ||
| 243 | } | ||
| 244 | Ok((revisions, current, path)) | ||
| 245 | })(); | ||
| 246 | let (revisions, current, capture) = captured.map_err(|error| CommitError { | ||
| 247 | state: CommitState::NotCommitted, | ||
| 248 | error: io::Error::other(error.to_string()), | ||
| 249 | })?; | ||
| 250 | let started = now(); | ||
| 251 | let result = self.remote.confirm(snapshot); | ||
| 252 | println!( | ||
| 253 | "{}", | ||
| 254 | json!({"event":"remote_confirm", "started_us":started, "finished_us":now(), "revisions":revisions, "current_revisions":current, "capture":capture.as_ref().and_then(|path| path.file_name()).map(|name| name.to_string_lossy()), "text":self.before.as_ref().map(|(text,_)|text), | ||
| 255 | "state":format!("{:?}", result.as_ref().map_or_else(|error| error.state, |_| CommitState::Committed)), "error":result.as_ref().err().map(|error|error.error.to_string())}) | ||
| 256 | ); | ||
| 257 | result | ||
| 258 | } | ||
| 259 | } | ||
| 260 | |||
| 261 | fn tokens(text: &str) -> Option<Vec<&str>> { | ||
| 262 | let mut remaining = text.strip_prefix("Concurrent edits:")?; | ||
| 263 | let mut tokens = Vec::new(); | ||
| 264 | while !remaining.is_empty() { | ||
| 265 | let body = remaining.strip_prefix(" [w")?; | ||
| 266 | let (token, tail) = body.split_once(']')?; | ||
| 267 | let (actor, operation) = token.split_once(':')?; | ||
| 268 | if actor.is_empty() | ||
| 269 | || operation.is_empty() | ||
| 270 | || !actor | ||
| 271 | .bytes() | ||
| 272 | .chain(operation.bytes()) | ||
| 273 | .all(|b| b.is_ascii_digit()) | ||
| 274 | || tokens.contains(&token) | ||
| 275 | { | ||
| 276 | return None; | ||
| 277 | } | ||
| 278 | tokens.push(token); | ||
| 279 | remaining = tail; | ||
| 280 | } | ||
| 281 | Some(tokens) | ||
| 282 | } | ||
| 283 | |||
| 284 | // This owned workload explicitly resolves append conflicts after all retained tokens. | ||
| 285 | fn append_position(before: &str, current: &str, token: &str) -> Option<u32> { | ||
| 286 | let original = tokens(before)?; | ||
| 287 | let present = tokens(current)?; | ||
| 288 | let mut retained = present.iter(); | ||
| 289 | for token in original { | ||
| 290 | retained.find(|&&candidate| candidate == token)?; | ||
| 291 | } | ||
| 292 | if current.contains(token) { | ||
| 293 | return None; | ||
| 294 | } | ||
| 295 | u32::try_from(current.encode_utf16().count()).ok() | ||
| 296 | } | ||
| 297 | |||
| 298 | fn queue_document( | ||
| 299 | cache: &Replica, | ||
| 300 | actor: &str, | ||
| 301 | operation: usize, | ||
| 302 | parent: Option<ExGuid>, | ||
| 303 | deadline: Instant, | ||
| 304 | ) -> Result<(ExGuid, [u64; DOCUMENT_OPERATIONS.len()]), Box<dyn std::error::Error>> { | ||
| 305 | let source = cache.snapshot()?; | ||
| 306 | let store = Store::parse(&source)?; | ||
| 307 | let index = RevisionIndex::parse(&store)?; | ||
| 308 | let document = Document::parse(&index)?; | ||
| 309 | let (space, page) = document.pages()?[0]; | ||
| 310 | let text = format!("Document {actor}:{operation} 🦀"); | ||
| 311 | let insertion = if operation.is_multiple_of(2) { | ||
| 312 | let column: u32 = actor | ||
| 313 | .strip_prefix('w') | ||
| 314 | .ok_or("Missing writer number")? | ||
| 315 | .parse()?; | ||
| 316 | Insertion::outline( | ||
| 317 | page, | ||
| 318 | 144.0 + column as f32 * 240.0, | ||
| 319 | 144.0 + operation as f32 * 72.0, | ||
| 320 | &text, | ||
| 321 | "Offline document writer", | ||
| 322 | )? | ||
| 323 | } else { | ||
| 324 | Insertion::paragraph( | ||
| 325 | parent.ok_or("Missing prior outline")?, | ||
| 326 | None, | ||
| 327 | &text, | ||
| 328 | "Offline document writer", | ||
| 329 | )? | ||
| 330 | } | ||
| 331 | .with_formatting( | ||
| 332 | 0..u32::try_from(text.encode_utf16().count())?, | ||
| 333 | &[ | ||
| 334 | TextAttribute::FontSize(13.5), | ||
| 335 | TextAttribute::Color(Some([0x44, 0x55, 0x66])), | ||
| 336 | ], | ||
| 337 | )?; | ||
| 338 | let parent = if operation.is_multiple_of(2) { | ||
| 339 | insertion.object() | ||
| 340 | } else { | ||
| 341 | parent.unwrap() | ||
| 342 | }; | ||
| 343 | let end = u32::try_from(text.encode_utf16().count())?; | ||
| 344 | let split = ParagraphSplit::new(insertion.text_object(), end - 2, "Offline document writer")?; | ||
| 345 | let tail_split = | ||
| 346 | ParagraphSplit::new(insertion.text_object(), end + 1, "Offline document writer")?; | ||
| 347 | let join = ParagraphJoin::new( | ||
| 348 | insertion.text_object(), | ||
| 349 | split.text_object(), | ||
| 350 | "Offline document writer", | ||
| 351 | )?; | ||
| 352 | let attributes = [ | ||
| 353 | TextAttribute::Bold(true), | ||
| 354 | TextAttribute::FontSize(18.0 + (operation % 9) as f32), | ||
| 355 | TextAttribute::Color(Some([0x12, 0x34, 0x56])), | ||
| 356 | ]; | ||
| 357 | let mut ids = [0; DOCUMENT_OPERATIONS.len()]; | ||
| 358 | for (step, id) in ids.iter_mut().enumerate() { | ||
| 359 | let kind = DOCUMENT_OPERATIONS[step]; | ||
| 360 | let tree = match kind { | ||
| 361 | "nest" => { | ||
| 362 | let source = cache.snapshot()?; | ||
| 363 | let store = Store::parse(&source)?; | ||
| 364 | let index = RevisionIndex::parse(&store)?; | ||
| 365 | let document = Document::parse(&index)?; | ||
| 366 | let section = &document.spaces[&space]; | ||
| 367 | let view = &section.revisions[&section.contexts[&ExGuid::default()]]; | ||
| 368 | let paragraph = view | ||
| 369 | .nodes | ||
| 370 | .iter() | ||
| 371 | .find(|(_, node)| node.content == [insertion.text_object()]) | ||
| 372 | .map(|(id, _)| *id) | ||
| 373 | .ok_or("Missing inserted paragraph")?; | ||
| 374 | Some(TreeEdit::move_to( | ||
| 375 | split.object(), | ||
| 376 | paragraph, | ||
| 377 | None, | ||
| 378 | "Offline document writer", | ||
| 379 | )?) | ||
| 380 | } | ||
| 381 | "unnest" => Some(TreeEdit::move_to( | ||
| 382 | split.object(), | ||
| 383 | parent, | ||
| 384 | None, | ||
| 385 | "Offline document writer", | ||
| 386 | )?), | ||
| 387 | "delete" => Some(TreeEdit::delete( | ||
| 388 | tail_split.object(), | ||
| 389 | "Offline document writer", | ||
| 390 | )?), | ||
| 391 | _ => None, | ||
| 392 | }; | ||
| 393 | let range = match kind { | ||
| 394 | "text" => end - 3..end, | ||
| 395 | "split" => end - 2..end - 2, | ||
| 396 | "tail_split" => end + 1..end + 1, | ||
| 397 | "right_text" => 0..2, | ||
| 398 | _ => 1..end - 2, | ||
| 399 | }; | ||
| 400 | let target = if kind == "right_text" { | ||
| 401 | split.text_object() | ||
| 402 | } else if kind == "delete" { | ||
| 403 | tail_split.text_object() | ||
| 404 | } else { | ||
| 405 | insertion.text_object() | ||
| 406 | }; | ||
| 407 | let replacement = match kind { | ||
| 408 | "text" => Some(" e\u{301}🐈"), | ||
| 409 | "right_text" => Some("B🦋"), | ||
| 410 | _ => None, | ||
| 411 | }; | ||
| 412 | loop { | ||
| 413 | if Instant::now() >= deadline { | ||
| 414 | return Err("Document queue timed out; cache retained".into()); | ||
| 415 | } | ||
| 416 | let source = cache.snapshot()?; | ||
| 417 | let started = now(); | ||
| 418 | let result = match kind { | ||
| 419 | "insert" => cache.insert(&source, space, &insertion), | ||
| 420 | "format" => cache.format(&source, space, target, range.clone(), &attributes), | ||
| 421 | "text" | "right_text" => { | ||
| 422 | cache.edit_text(&source, space, target, range.clone(), replacement.unwrap()) | ||
| 423 | } | ||
| 424 | "split" => cache.split(&source, space, &split), | ||
| 425 | "tail_split" => cache.split(&source, space, &tail_split), | ||
| 426 | "join" => cache.join(&source, space, &join), | ||
| 427 | "nest" | "unnest" | "delete" => cache.tree(&source, space, tree.as_ref().unwrap()), | ||
| 428 | _ => unreachable!(), | ||
| 429 | }; | ||
| 430 | match result { | ||
| 431 | Ok(Some(acknowledged)) => { | ||
| 432 | *id = acknowledged; | ||
| 433 | println!( | ||
| 434 | "{}", | ||
| 435 | json!({"event":"local_document_commit","id":acknowledged,"operation":operation,"kind":kind, | ||
| 436 | "space":space.to_string(),"object":target.to_string(),"document":insertion.text_object().to_string(), | ||
| 437 | "text":text,"insertion":if kind=="insert" {Some(&insertion)} else {None}, | ||
| 438 | "split":match kind { "split" => Some(&split), "tail_split" => Some(&tail_split), _ => None }, | ||
| 439 | "tree":tree, | ||
| 440 | "joined":if kind=="join" {Some(join.texts().map(|id| id.to_string()))} else {None}, | ||
| 441 | "range":[range.start,range.end],"attributes":attributes,"replacement":replacement, | ||
| 442 | "started_us":started,"finished_us":now()}) | ||
| 443 | ); | ||
| 444 | break; | ||
| 445 | } | ||
| 446 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {} | ||
| 447 | other => return Err(format!("Unexpected document queue result: {other:?}").into()), | ||
| 448 | } | ||
| 449 | } | ||
| 450 | } | ||
| 451 | Ok((parent, ids)) | ||
| 452 | } | ||
| 453 | |||
| 454 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 455 | let args: Vec<_> = env::args().skip(1).collect(); | ||
| 456 | if args.len() != 7 | ||
| 457 | || !["read", "write"].contains(&args[0].as_str()) | ||
| 458 | || !args[2].bytes().all(|b| b.is_ascii_alphanumeric()) | ||
| 459 | { | ||
| 460 | return Err("Expected read|write FILE ACTOR OPERATIONS START_FILE STOP_FILE SEED".into()); | ||
| 461 | } | ||
| 462 | let documents = env::var_os("ONESTORE_OFFLINE_DOCUMENTS").is_some(); | ||
| 463 | let address = env::var("ONESTORE_SMB_LAB")?; | ||
| 464 | let share = env::var("ONESTORE_SMB_SHARE")?; | ||
| 465 | let initial = Client::connect( | ||
| 466 | &address, | ||
| 467 | &share, | ||
| 468 | Credentials::default(), | ||
| 469 | Duration::from_secs(5), | ||
| 470 | )?; | ||
| 471 | if args[0] == "read" { | ||
| 472 | return concurrent::run( | ||
| 473 | &args, | ||
| 474 | |path| initial.read(path, 256 * 1024 * 1024), | ||
| 475 | |_, _, _, _, _, _| unreachable!(), | ||
| 476 | ); | ||
| 477 | } | ||
| 478 | let timeout: u64 = env::var("ONESTORE_CLIENT_TIMEOUT_MS") | ||
| 479 | .unwrap_or_else(|_| "600000".into()) | ||
| 480 | .parse()?; | ||
| 481 | let deadline = Instant::now() | ||
| 482 | .checked_add(Duration::from_millis(timeout)) | ||
| 483 | .ok_or("Invalid timeout")?; | ||
| 484 | let operations: usize = args[3].parse()?; | ||
| 485 | let mut seed: u64 = args[6].parse()?; | ||
| 486 | if operations == 0 { | ||
| 487 | return Err("Expected positive operations".into()); | ||
| 488 | } | ||
| 489 | let source = initial.read(&args[1], 256 * 1024 * 1024)?; | ||
| 490 | drop(initial); | ||
| 491 | let cache_path = Path::new(&args[4]) | ||
| 492 | .parent() | ||
| 493 | .ok_or("Missing workload directory")? | ||
| 494 | .join(format!("{}.sqlite", args[2])); | ||
| 495 | let cache = Arc::new(Replica::create(&cache_path, &source)?); | ||
| 496 | println!( | ||
| 497 | "{}", | ||
| 498 | json!({"event":"ready", "pid":std::process::id(), "actor":args[2], "offline":true, "document_operations":documents,"document_graph":documents,"document_kinds":if documents {DOCUMENT_OPERATIONS.as_slice()} else {&[]}}) | ||
| 499 | ); | ||
| 500 | while !Path::new(&args[4]).exists() { | ||
| 501 | if Instant::now() >= deadline { | ||
| 502 | return Err("Start barrier timed out".into()); | ||
| 503 | } | ||
| 504 | thread::sleep(Duration::from_millis(5)); | ||
| 505 | } | ||
| 506 | let path = args[1].clone(); | ||
| 507 | let outage = env::var_os("ONESTORE_OFFLINE_OUTAGE_DIR").map(PathBuf::from); | ||
| 508 | let pause = outage | ||
| 509 | .as_ref() | ||
| 510 | .map(|directory| Pause::Outage(directory.join(format!("offline-paused-{}", args[2])))) | ||
| 511 | .or_else(|| { | ||
| 512 | env::var_os("ONESTORE_OFFLINE_FORMAT_REPLY_DIR").map(|directory| { | ||
| 513 | Pause::FormatReply( | ||
| 514 | PathBuf::from(directory).join(format!("offline-paused-{}", args[2])), | ||
| 515 | ) | ||
| 516 | }) | ||
| 517 | }); | ||
| 518 | let (fatal_tx, fatal_rx) = std::sync::mpsc::channel(); | ||
| 519 | let worker = cache.start_sync(Duration::from_millis(50), move || { | ||
| 520 | let client = Client::connect(&address, &share, Credentials::default(), Duration::from_secs(5))?; | ||
| 521 | println!("{}", json!({"event":"transport_connected", "at_us":now()})); | ||
| 522 | Ok(Traced { remote: SmbRemote::new(client, &path, 256 * 1024 * 1024), before:None, pause:pause.clone(), documents }) | ||
| 523 | }, move |result| { | ||
| 524 | if let Err(error) = result { | ||
| 525 | println!("{}", json!({"event":"sync_error", "error":error.to_string(), "at_us":now()})); | ||
| 526 | if !matches!(error, Error::RemoteIo(_) | Error::Remote(_)) && !matches!(error, Error::Io(error) if error.kind() == io::ErrorKind::WouldBlock) { | ||
| 527 | let _ = fatal_tx.send(error.to_string()); | ||
| 528 | } | ||
| 529 | } | ||
| 530 | })?; | ||
| 531 | let mut ids = Vec::new(); | ||
| 532 | let mut document_ids = std::collections::BTreeSet::new(); | ||
| 533 | let mut document_parent = None; | ||
| 534 | let result = (|| -> Result<(), Box<dyn std::error::Error>> { | ||
| 535 | let mut generated = 0; | ||
| 536 | let mut received = 0; | ||
| 537 | loop { | ||
| 538 | match fatal_rx.try_recv() { | ||
| 539 | Ok(error) => return Err(error.into()), | ||
| 540 | Err(std::sync::mpsc::TryRecvError::Disconnected) => { | ||
| 541 | return Err("Worker exited before completion".into()); | ||
| 542 | } | ||
| 543 | Err(std::sync::mpsc::TryRecvError::Empty) => {} | ||
| 544 | } | ||
| 545 | while received < ids.len() { | ||
| 546 | match cache.status(ids[received])? { | ||
| 547 | Some(EditStatus::Published { revision }) => { | ||
| 548 | println!( | ||
| 549 | "{}", | ||
| 550 | json!({"event":if document_ids.contains(&ids[received]) {"document_receipt"} else {"remote_receipt"}, "id":ids[received], "revision":revision.to_string(), "at_us":now()}) | ||
| 551 | ); | ||
| 552 | received += 1; | ||
| 553 | } | ||
| 554 | Some(_) => break, | ||
| 555 | None => return Err("Local intent disappeared".into()), | ||
| 556 | } | ||
| 557 | } | ||
| 558 | if Instant::now() >= deadline { | ||
| 559 | return Err("Offline workload timed out; cache retained".into()); | ||
| 560 | } | ||
| 561 | let pending = cache.pending()?; | ||
| 562 | let capacity = if outage | ||
| 563 | .as_ref() | ||
| 564 | .is_some_and(|directory| !directory.join("offline-outage-down").exists()) | ||
| 565 | { | ||
| 566 | 1 | ||
| 567 | } else if documents && outage.is_some() { | ||
| 568 | 8 * (1 + DOCUMENT_OPERATIONS.len()) | ||
| 569 | } else { | ||
| 570 | 8 | ||
| 571 | }; | ||
| 572 | if generated < operations && pending.len() < capacity { | ||
| 573 | let source = cache.snapshot()?; | ||
| 574 | let target = view(&source)?; | ||
| 575 | let at = u32::try_from(target.text.encode_utf16().count())?; | ||
| 576 | let token = format!(" [{}:{}]", args[2], generated); | ||
| 577 | let started = now(); | ||
| 578 | match cache.edit_text(&source, target.space, target.object, at..at, &token) { | ||
| 579 | Ok(Some(id)) => { | ||
| 580 | println!( | ||
| 581 | "{}", | ||
| 582 | json!({"event":"local_commit", "id":id, "operation":generated, "space":target.space.to_string(), "object":target.object.to_string(), "before":target.text, "token":token, "started_us":started, "finished_us":now()}) | ||
| 583 | ); | ||
| 584 | ids.push(id); | ||
| 585 | if documents { | ||
| 586 | let (parent, added) = queue_document( | ||
| 587 | &cache, | ||
| 588 | &args[2], | ||
| 589 | generated, | ||
| 590 | document_parent, | ||
| 591 | deadline, | ||
| 592 | )?; | ||
| 593 | document_parent = Some(parent); | ||
| 594 | document_ids.extend(added); | ||
| 595 | ids.extend(added); | ||
| 596 | } | ||
| 597 | generated += 1; | ||
| 598 | } | ||
| 599 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {} | ||
| 600 | other => return Err(format!("Unexpected local result: {other:?}").into()), | ||
| 601 | } | ||
| 602 | } | ||
| 603 | if let Some(intent) = pending.first() | ||
| 604 | && matches!(cache.status(intent.id)?, Some(EditStatus::Conflict(_))) | ||
| 605 | { | ||
| 606 | let local = cache.snapshot()?; | ||
| 607 | let remote = cache.remote_snapshot()?; | ||
| 608 | let current = view(&remote)?; | ||
| 609 | let notebook::Operation::Text(edit) = &intent.operation else { | ||
| 610 | return Err(format!( | ||
| 611 | "Document intent {} requires review: {:?}", | ||
| 612 | intent.id, | ||
| 613 | cache.status(intent.id)? | ||
| 614 | ) | ||
| 615 | .into()); | ||
| 616 | }; | ||
| 617 | let at = append_position(&edit.before, &current.text, &edit.replacement) | ||
| 618 | .ok_or("Append model disagrees with retained history")?; | ||
| 619 | match cache.rebase_conflict(intent.id, &local, &remote, at..at) { | ||
| 620 | Ok(()) => println!( | ||
| 621 | "{}", | ||
| 622 | json!({"event":"reviewed_append", "id":intent.id, "before":edit.before, "remote":current.text, "token":edit.replacement, "at_us":now()}) | ||
| 623 | ), | ||
| 624 | Err(Error::Io(error)) | ||
| 625 | if [ | ||
| 626 | io::ErrorKind::ResourceBusy, | ||
| 627 | io::ErrorKind::WouldBlock, | ||
| 628 | io::ErrorKind::InvalidInput, | ||
| 629 | ] | ||
| 630 | .contains(&error.kind()) => {} | ||
| 631 | Err(error) => return Err(error.into()), | ||
| 632 | } | ||
| 633 | } | ||
| 634 | if generated == operations && received == ids.len() { | ||
| 635 | if !cache.pending()?.is_empty() { | ||
| 636 | return Err("Acknowledged queue did not drain".into()); | ||
| 637 | } | ||
| 638 | break; | ||
| 639 | } | ||
| 640 | seed = seed | ||
| 641 | .wrapping_mul(6364136223846793005) | ||
| 642 | .wrapping_add(1442695040888963407); | ||
| 643 | thread::sleep(Duration::from_millis(1 + (seed >> 32) % 7)); | ||
| 644 | } | ||
| 645 | Ok(()) | ||
| 646 | })(); | ||
| 647 | let stopped = worker.stop(); | ||
| 648 | result?; | ||
| 649 | stopped?; | ||
| 650 | drop(cache); | ||
| 651 | let reopened = Replica::open(&cache_path)?; | ||
| 652 | if !reopened.pending()?.is_empty() { | ||
| 653 | return Err("Pending edits reappeared after reopen".into()); | ||
| 654 | } | ||
| 655 | for id in ids { | ||
| 656 | let Some(EditStatus::Published { revision }) = reopened.status(id)? else { | ||
| 657 | return Err("Receipt did not survive reopen".into()); | ||
| 658 | }; | ||
| 659 | println!( | ||
| 660 | "{}", | ||
| 661 | json!({"event":if document_ids.contains(&id) {"reopened_document_receipt"} else {"reopened_receipt"}, "id":id, "revision":revision.to_string()}) | ||
| 662 | ); | ||
| 663 | } | ||
| 664 | println!( | ||
| 665 | "{}", | ||
| 666 | json!({"event":"done", "operations":operations, "at_us":now()}) | ||
| 667 | ); | ||
| 668 | Ok(()) | ||
| 669 | } | ||
| 670 | |||
| 671 | #[test] | ||
| 672 | fn append_review_requires_a_unique_ordered_history_and_an_absent_new_token() { | ||
| 673 | assert_eq!( | ||
| 674 | append_position( | ||
| 675 | "Concurrent edits: [w0:0]", | ||
| 676 | "Concurrent edits: [w1:0] [w0:0]", | ||
| 677 | " [w0:1]" | ||
| 678 | ), | ||
| 679 | Some(31) | ||
| 680 | ); | ||
| 681 | for current in [ | ||
| 682 | "Concurrent edits:", | ||
| 683 | "Concurrent edits: [w0:0] [w0:0]", | ||
| 684 | "Concurrent edits: [w0:1] [w0:0]", | ||
| 685 | "Concurrent edits: changed [w0:0]", | ||
| 686 | ] { | ||
| 687 | assert_eq!( | ||
| 688 | append_position("Concurrent edits: [w0:0]", current, " [w0:1]"), | ||
| 689 | None | ||
| 690 | ); | ||
| 691 | } | ||
| 692 | assert_eq!( | ||
| 693 | append_position( | ||
| 694 | "Concurrent edits: [w0:0] [w1:0]", | ||
| 695 | "Concurrent edits: [w1:0] [w0:0]", | ||
| 696 | " [w0:1]" | ||
| 697 | ), | ||
| 698 | None | ||
| 699 | ); | ||
| 700 | } | ||
| 701 | |||
| 702 | #[cfg(test)] | ||
| 703 | #[path = "../../onestore/tests/support/disk.rs"] | ||
| 704 | mod disk; | ||
| 705 | |||
| 706 | #[cfg(test)] | ||
| 707 | mod tests { | ||
| 708 | use super::*; | ||
| 709 | |||
| 710 | #[test] | ||
| 711 | fn document_workload_retains_dependencies_and_receipts_across_reopen() { | ||
| 712 | struct Server { | ||
| 713 | disk: disk::Disk, | ||
| 714 | lost_reply: bool, | ||
| 715 | } | ||
| 716 | impl Remote for Server { | ||
| 717 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 718 | Ok(self.disk.visible.clone()) | ||
| 719 | } | ||
| 720 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 721 | edit.commit(&mut self.disk)?; | ||
| 722 | if std::mem::take(&mut self.lost_reply) { | ||
| 723 | Err(CommitError { | ||
| 724 | state: CommitState::Unknown, | ||
| 725 | error: io::Error::from(io::ErrorKind::ConnectionAborted), | ||
| 726 | }) | ||
| 727 | } else { | ||
| 728 | Ok(()) | ||
| 729 | } | ||
| 730 | } | ||
| 731 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 732 | onestore::confirm_snapshot(&mut self.disk, snapshot) | ||
| 733 | } | ||
| 734 | } | ||
| 735 | let source = | ||
| 736 | onestore::create_section("workload.one", "Concurrent edits:", "Author").unwrap(); | ||
| 737 | let directory = tempfile::tempdir().unwrap(); | ||
| 738 | let path = directory.path().join("cache.sqlite"); | ||
| 739 | let mut cache = Replica::create(&path, &source).unwrap(); | ||
| 740 | let mut remote = Traced { | ||
| 741 | remote: Server { | ||
| 742 | disk: disk::Disk { | ||
| 743 | visible: source.clone(), | ||
| 744 | durable: source.clone(), | ||
| 745 | operation: 0, | ||
| 746 | fail_at: None, | ||
| 747 | write_limit: 97, | ||
| 748 | random: 1, | ||
| 749 | }, | ||
| 750 | lost_reply: false, | ||
| 751 | }, | ||
| 752 | before: None, | ||
| 753 | pause: None, | ||
| 754 | documents: true, | ||
| 755 | }; | ||
| 756 | println!( | ||
| 757 | "{}", | ||
| 758 | json!({"event":"ready", "actor":"w0", "offline":true, | ||
| 759 | "document_operations":true, "document_graph":true, "document_kinds":DOCUMENT_OPERATIONS}) | ||
| 760 | ); | ||
| 761 | let deadline = Instant::now() + Duration::from_secs(30); | ||
| 762 | let mut parent = None; | ||
| 763 | let mut ids = Vec::new(); | ||
| 764 | for operation in 0..2 { | ||
| 765 | let (outline, added) = | ||
| 766 | queue_document(&cache, "w0", operation, parent, deadline).unwrap(); | ||
| 767 | parent = Some(outline); | ||
| 768 | ids.extend(added); | ||
| 769 | drop(cache); | ||
| 770 | cache = Replica::open(&path).unwrap(); | ||
| 771 | } | ||
| 772 | let local = cache.snapshot().unwrap(); | ||
| 773 | assert_eq!( | ||
| 774 | cache.pending().unwrap().len(), | ||
| 775 | 2 * DOCUMENT_OPERATIONS.len() | ||
| 776 | ); | ||
| 777 | for (step, id) in ids.iter().enumerate() { | ||
| 778 | remote.remote.lost_reply = matches!( | ||
| 779 | DOCUMENT_OPERATIONS[step % DOCUMENT_OPERATIONS.len()], | ||
| 780 | "split" | "nest" | "unnest" | "join" | "tail_split" | "delete" | ||
| 781 | ); | ||
| 782 | let result = cache.sync_once(&mut remote); | ||
| 783 | let state = if result.is_err() { | ||
| 784 | assert!(matches!( | ||
| 785 | result, | ||
| 786 | Err(Error::Remote(CommitError { | ||
| 787 | state: CommitState::Unknown, | ||
| 788 | .. | ||
| 789 | })) | ||
| 790 | )); | ||
| 791 | drop(cache); | ||
| 792 | cache = Replica::open(&path).unwrap(); | ||
| 793 | cache.sync_once(&mut remote).unwrap().unwrap() | ||
| 794 | } else { | ||
| 795 | result.unwrap().unwrap() | ||
| 796 | }; | ||
| 797 | assert_eq!(state.0, *id); | ||
| 798 | let EditStatus::Published { revision } = state.1 else { | ||
| 799 | panic!("{state:?}") | ||
| 800 | }; | ||
| 801 | println!( | ||
| 802 | "{}", | ||
| 803 | json!({"event":"document_receipt", "id":id, "revision":revision.to_string(), "at_us":now()}) | ||
| 804 | ); | ||
| 805 | drop(cache); | ||
| 806 | cache = Replica::open(&path).unwrap(); | ||
| 807 | assert_eq!( | ||
| 808 | cache.status(*id).unwrap(), | ||
| 809 | Some(EditStatus::Published { revision }) | ||
| 810 | ); | ||
| 811 | if step + 1 < ids.len() { | ||
| 812 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 813 | } | ||
| 814 | } | ||
| 815 | assert!(cache.pending().unwrap().is_empty()); | ||
| 816 | for id in ids { | ||
| 817 | let Some(EditStatus::Published { revision }) = cache.status(id).unwrap() else { | ||
| 818 | panic!() | ||
| 819 | }; | ||
| 820 | println!( | ||
| 821 | "{}", | ||
| 822 | json!({"event":"reopened_document_receipt", "id":id, "revision":revision.to_string()}) | ||
| 823 | ); | ||
| 824 | } | ||
| 825 | remote.read().unwrap(); | ||
| 826 | println!("{}", json!({"event":"done"})); | ||
| 827 | } | ||
| 828 | |||
| 829 | #[test] | ||
| 830 | fn publication_differences_retain_removed_text_and_graph_identities() { | ||
| 831 | let before = DocumentView { | ||
| 832 | texts: serde_json::from_value(json!({"left":{"text":"ab"}, "right":{"text":"cd"}, "untouched":{"text":"ef"}})).unwrap(), | ||
| 833 | graph: serde_json::from_value(json!({"outline":{"children":["a","b"]}, "a":{"content":["left"]}, "b":{"content":["right"]}})).unwrap(), | ||
| 834 | }; | ||
| 835 | let after = DocumentView { | ||
| 836 | texts: serde_json::from_value( | ||
| 837 | json!({"left":{"text":"abcd"}, "untouched":{"text":"ef"}}), | ||
| 838 | ) | ||
| 839 | .unwrap(), | ||
| 840 | graph: serde_json::from_value( | ||
| 841 | json!({"outline":{"children":["a"]}, "a":{"content":["left"]}}), | ||
| 842 | ) | ||
| 843 | .unwrap(), | ||
| 844 | }; | ||
| 845 | let changes = after.changes(&before); | ||
| 846 | assert_eq!( | ||
| 847 | changes.texts, | ||
| 848 | serde_json::from_value::<serde_json::Map<_, _>>( | ||
| 849 | json!({"left":{"text":"abcd"}, "right":null}) | ||
| 850 | ) | ||
| 851 | .unwrap() | ||
| 852 | ); | ||
| 853 | assert_eq!( | ||
| 854 | changes.graph, | ||
| 855 | serde_json::from_value::<serde_json::Map<_, _>>( | ||
| 856 | json!({"outline":{"children":["a"]}, "b":null}) | ||
| 857 | ) | ||
| 858 | .unwrap() | ||
| 859 | ); | ||
| 860 | assert_eq!(before.changes(&after).texts["right"], before.texts["right"]); | ||
| 861 | assert_eq!(before.changes(&before), DocumentView::default()); | ||
| 862 | } | ||
| 863 | } | ||
crates/notebook/examples/smb_reconnect_client.rs created+267| ... | @@ -0,0 +1,267 @@ | ||
| 1 | #[path = "../../onestore/examples/support/concurrent.rs"] | ||
| 2 | mod concurrent; | ||
| 3 | |||
| 4 | use notebook::smb::{Client, Credentials}; | ||
| 5 | use onestore::{ | ||
| 6 | CommitError, CommitState, ExGuid, RevisionIndex, Store, | ||
| 7 | document::{Document, Kind}, | ||
| 8 | }; | ||
| 9 | use serde_json::json; | ||
| 10 | use std::{ | ||
| 11 | cell::RefCell, | ||
| 12 | env, io, thread, | ||
| 13 | time::{Duration, Instant, SystemTime, UNIX_EPOCH}, | ||
| 14 | }; | ||
| 15 | |||
| 16 | fn paragraph( | ||
| 17 | bytes: &[u8], | ||
| 18 | space: ExGuid, | ||
| 19 | object: ExGuid, | ||
| 20 | ) -> Result<String, Box<dyn std::error::Error>> { | ||
| 21 | let store = Store::parse(bytes)?; | ||
| 22 | let index = RevisionIndex::parse(&store)?; | ||
| 23 | index.validate_current()?; | ||
| 24 | let document = Document::parse(&index)?; | ||
| 25 | let space = &document.spaces[&space]; | ||
| 26 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 27 | let Kind::RichText { text, .. } = &revision.nodes[&object].kind else { | ||
| 28 | return Err("The append target is no longer text.".into()); | ||
| 29 | }; | ||
| 30 | Ok(text.clone()) | ||
| 31 | } | ||
| 32 | |||
| 33 | // Only the owned append workload guarantees unique tokens that no writer removes. | ||
| 34 | fn retained(before: &str, token: &str, current: &str, state: CommitState) -> io::Result<bool> { | ||
| 35 | let count = current.matches(token).count(); | ||
| 36 | if count == 0 && state != CommitState::Committed && current.starts_with(before) { | ||
| 37 | return Ok(false); | ||
| 38 | } | ||
| 39 | if count == 1 | ||
| 40 | && state != CommitState::NotCommitted | ||
| 41 | && current.starts_with(&format!("{before}{token}")) | ||
| 42 | { | ||
| 43 | return Ok(true); | ||
| 44 | } | ||
| 45 | Err(io::Error::other( | ||
| 46 | "The append history contradicts the commit outcome.", | ||
| 47 | )) | ||
| 48 | } | ||
| 49 | |||
| 50 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 51 | let args: Vec<_> = env::args().skip(1).collect(); | ||
| 52 | if args | ||
| 53 | .first() | ||
| 54 | .is_none_or(|mode| !["read", "write"].contains(&mode.as_str())) | ||
| 55 | { | ||
| 56 | return Err("Reconnect testing requires the append-only workload.".into()); | ||
| 57 | } | ||
| 58 | let address = env::var("ONESTORE_SMB_LAB")?; | ||
| 59 | let share = env::var("ONESTORE_SMB_SHARE")?; | ||
| 60 | let client = RefCell::new(Some(Client::connect( | ||
| 61 | &address, | ||
| 62 | &share, | ||
| 63 | Credentials::default(), | ||
| 64 | Duration::from_secs(5), | ||
| 65 | )?)); | ||
| 66 | let read = |path: &str| { | ||
| 67 | let mut session = client.borrow_mut(); | ||
| 68 | if session.is_none() { | ||
| 69 | match Client::connect( | ||
| 70 | &address, | ||
| 71 | &share, | ||
| 72 | Credentials::default(), | ||
| 73 | Duration::from_secs(5), | ||
| 74 | ) { | ||
| 75 | Ok(fresh) => { | ||
| 76 | *session = Some(fresh); | ||
| 77 | println!( | ||
| 78 | "{}", | ||
| 79 | json!({"event":"transport_connected", "at_us":SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_micros()}) | ||
| 80 | ); | ||
| 81 | } | ||
| 82 | Err(error) => { | ||
| 83 | println!( | ||
| 84 | "{}", | ||
| 85 | json!({"event":"transport_connect_error","error":error.to_string()}) | ||
| 86 | ); | ||
| 87 | return Err(io::ErrorKind::WouldBlock.into()); | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | let started = SystemTime::now() | ||
| 92 | .duration_since(UNIX_EPOCH) | ||
| 93 | .unwrap() | ||
| 94 | .as_micros(); | ||
| 95 | match session.as_ref().unwrap().read(path, 256 * 1024 * 1024) { | ||
| 96 | Ok(bytes) => { | ||
| 97 | if args.get(2).is_some_and(|actor| actor == "r0") | ||
| 98 | && let Some(folder) = env::var_os("ONESTORE_OFFLINE_FORMAT_REPLY_DIR") | ||
| 99 | { | ||
| 100 | let folder = std::path::PathBuf::from(folder); | ||
| 101 | let captured = folder.join("offline-retired.one"); | ||
| 102 | if !captured.exists() | ||
| 103 | && let Ok(marker) = std::fs::read(folder.join("offline-paused-w0.isolate")) | ||
| 104 | && let Ok(watched) = serde_json::from_slice::<serde_json::Value>(&marker) | ||
| 105 | && watched["after_us"] | ||
| 106 | .as_u64() | ||
| 107 | .is_some_and(|after| started > u128::from(after)) | ||
| 108 | { | ||
| 109 | let sid: ExGuid = watched["space"] | ||
| 110 | .as_str() | ||
| 111 | .ok_or_else(|| io::Error::other("Missing watched space"))? | ||
| 112 | .parse() | ||
| 113 | .map_err(io::Error::other)?; | ||
| 114 | let rid: ExGuid = watched["revision"] | ||
| 115 | .as_str() | ||
| 116 | .ok_or_else(|| io::Error::other("Missing watched revision"))? | ||
| 117 | .parse() | ||
| 118 | .map_err(io::Error::other)?; | ||
| 119 | let store = Store::parse(&bytes).map_err(io::Error::other)?; | ||
| 120 | let index = RevisionIndex::parse(&store).map_err(io::Error::other)?; | ||
| 121 | if index | ||
| 122 | .spaces | ||
| 123 | .get(&sid) | ||
| 124 | .is_some_and(|space| !space.revisions.contains_key(&rid)) | ||
| 125 | { | ||
| 126 | index.validate_current().map_err(io::Error::other)?; | ||
| 127 | std::fs::write(captured.with_extension("tmp"), &bytes)?; | ||
| 128 | std::fs::rename(captured.with_extension("tmp"), captured)?; | ||
| 129 | println!( | ||
| 130 | "{}", | ||
| 131 | json!({"event":"revision_retired", "space":sid.to_string(), "revision":rid.to_string(), "started_us":started, "finished_us":SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_micros()}) | ||
| 132 | ); | ||
| 133 | } | ||
| 134 | } | ||
| 135 | } | ||
| 136 | Ok(bytes) | ||
| 137 | } | ||
| 138 | Err(error) | ||
| 139 | if matches!( | ||
| 140 | error.kind(), | ||
| 141 | io::ErrorKind::Other | io::ErrorKind::TimedOut | io::ErrorKind::NotConnected | ||
| 142 | ) => | ||
| 143 | { | ||
| 144 | println!( | ||
| 145 | "{}", | ||
| 146 | json!({"event":"transport_read_error","error":error.to_string()}) | ||
| 147 | ); | ||
| 148 | *session = None; | ||
| 149 | Err(io::ErrorKind::WouldBlock.into()) | ||
| 150 | } | ||
| 151 | result => result, | ||
| 152 | } | ||
| 153 | }; | ||
| 154 | concurrent::run( | ||
| 155 | &args, | ||
| 156 | read, | ||
| 157 | |path, source, space, object, range, replacement| { | ||
| 158 | let outcome = client.borrow().as_ref().unwrap().commit_text( | ||
| 159 | path, | ||
| 160 | source, | ||
| 161 | space, | ||
| 162 | object, | ||
| 163 | range, | ||
| 164 | replacement, | ||
| 165 | ); | ||
| 166 | let Err(error) = outcome else { | ||
| 167 | return Ok(()); | ||
| 168 | }; | ||
| 169 | if error.state == CommitState::NotCommitted | ||
| 170 | && matches!( | ||
| 171 | error.error.kind(), | ||
| 172 | io::ErrorKind::WouldBlock | ||
| 173 | | io::ErrorKind::ResourceBusy | ||
| 174 | | io::ErrorKind::PermissionDenied | ||
| 175 | | io::ErrorKind::NotFound | ||
| 176 | ) | ||
| 177 | { | ||
| 178 | return Err(error); | ||
| 179 | } | ||
| 180 | println!( | ||
| 181 | "{}", | ||
| 182 | json!({"event":"transport_commit_error","state":format!("{:?}",error.state),"token":replacement,"error":error.error.to_string()}) | ||
| 183 | ); | ||
| 184 | *client.borrow_mut() = None; | ||
| 185 | let deadline = Instant::now() + Duration::from_secs(60); | ||
| 186 | loop { | ||
| 187 | if Instant::now() >= deadline { | ||
| 188 | return Err(error); | ||
| 189 | } | ||
| 190 | let current = match read(path) { | ||
| 191 | Ok(bytes) => bytes, | ||
| 192 | Err(retry) if retry.kind() == io::ErrorKind::WouldBlock => { | ||
| 193 | thread::sleep(Duration::from_millis(100)); | ||
| 194 | continue; | ||
| 195 | } | ||
| 196 | Err(_) => return Err(error), | ||
| 197 | }; | ||
| 198 | let published = paragraph(source, space, object) | ||
| 199 | .and_then(|before| { | ||
| 200 | Ok(retained( | ||
| 201 | &before, | ||
| 202 | replacement, | ||
| 203 | &paragraph(&current, space, object)?, | ||
| 204 | error.state, | ||
| 205 | )?) | ||
| 206 | }) | ||
| 207 | .map_err(|failure| CommitError { | ||
| 208 | state: error.state, | ||
| 209 | error: io::Error::other(failure.to_string()), | ||
| 210 | })?; | ||
| 211 | if published { | ||
| 212 | let confirmation = client.borrow().as_ref().unwrap().commit_text( | ||
| 213 | path, | ||
| 214 | &current, | ||
| 215 | space, | ||
| 216 | object, | ||
| 217 | 0..0, | ||
| 218 | "", | ||
| 219 | ); | ||
| 220 | if let Err(failure) = confirmation | ||
| 221 | && failure.state != CommitState::Committed | ||
| 222 | { | ||
| 223 | println!( | ||
| 224 | "{}", | ||
| 225 | json!({"event":"transport_confirmation_error","state":format!("{:?}", failure.state),"error":failure.error.to_string()}) | ||
| 226 | ); | ||
| 227 | *client.borrow_mut() = None; | ||
| 228 | thread::sleep(Duration::from_millis(100)); | ||
| 229 | continue; | ||
| 230 | } | ||
| 231 | println!( | ||
| 232 | "{}", | ||
| 233 | json!({"event":"transport_reconciled","token":replacement,"published":true,"flush_confirmed":true}) | ||
| 234 | ); | ||
| 235 | return Ok(()); | ||
| 236 | } | ||
| 237 | println!( | ||
| 238 | "{}", | ||
| 239 | json!({"event":"transport_reconciled","token":replacement,"published":false}) | ||
| 240 | ); | ||
| 241 | return Err(CommitError { | ||
| 242 | state: CommitState::NotCommitted, | ||
| 243 | error: io::ErrorKind::ResourceBusy.into(), | ||
| 244 | }); | ||
| 245 | } | ||
| 246 | }, | ||
| 247 | ) | ||
| 248 | } | ||
| 249 | |||
| 250 | #[test] | ||
| 251 | fn uncertain_append_requires_one_retained_token_and_its_predecessor() { | ||
| 252 | for state in [CommitState::Unknown, CommitState::Committed] { | ||
| 253 | assert!(retained("before", " [w0:0]", "before [w0:0] [w1:0]", state).unwrap()); | ||
| 254 | } | ||
| 255 | for state in [CommitState::Unknown, CommitState::NotCommitted] { | ||
| 256 | assert!(!retained("before", " [w0:0]", "before [w1:0]", state).unwrap()); | ||
| 257 | } | ||
| 258 | for (current, state) in [ | ||
| 259 | ("before [w0:0] [w0:0]", CommitState::Unknown), | ||
| 260 | ("changed [w0:0]", CommitState::Unknown), | ||
| 261 | ("befor", CommitState::Unknown), | ||
| 262 | ("before", CommitState::Committed), | ||
| 263 | ("before [w0:0]", CommitState::NotCommitted), | ||
| 264 | ] { | ||
| 265 | assert!(retained("before", " [w0:0]", current, state).is_err()); | ||
| 266 | } | ||
| 267 | } | ||
crates/notebook/examples/support/view.rs created+57| ... | @@ -0,0 +1,57 @@ | ||
| 1 | use onestore::{ | ||
| 2 | ExGuid, RevisionIndex, Store, | ||
| 3 | document::{Document, Kind}, | ||
| 4 | }; | ||
| 5 | |||
| 6 | pub struct View { | ||
| 7 | pub space: ExGuid, | ||
| 8 | pub object: ExGuid, | ||
| 9 | pub revision: ExGuid, | ||
| 10 | pub text: String, | ||
| 11 | } | ||
| 12 | |||
| 13 | pub fn view(bytes: &[u8]) -> Result<View, Box<dyn std::error::Error>> { | ||
| 14 | let store = Store::parse(bytes)?; | ||
| 15 | if !store.checksum_mismatches.is_empty() { | ||
| 16 | return Err("Transaction checksum damage".into()); | ||
| 17 | } | ||
| 18 | let index = RevisionIndex::parse(&store)?; | ||
| 19 | index.validate_current()?; | ||
| 20 | let document = Document::parse(&index)?; | ||
| 21 | let mut found = Vec::new(); | ||
| 22 | for (sid, page) in document.pages()? { | ||
| 23 | let space = &document.spaces[&sid]; | ||
| 24 | let rid = space.contexts[&ExGuid::default()]; | ||
| 25 | let revision = &space.revisions[&rid]; | ||
| 26 | let mut pending = vec![page]; | ||
| 27 | let mut seen = std::collections::BTreeSet::new(); | ||
| 28 | while let Some(oid) = pending.pop() { | ||
| 29 | if !seen.insert(oid) { | ||
| 30 | continue; | ||
| 31 | } | ||
| 32 | let node = &revision.nodes[&oid]; | ||
| 33 | pending.extend( | ||
| 34 | node.children | ||
| 35 | .iter() | ||
| 36 | .chain(&node.content) | ||
| 37 | .chain(&node.structure) | ||
| 38 | .copied(), | ||
| 39 | ); | ||
| 40 | if let Kind::RichText { text, .. } = &node.kind | ||
| 41 | && text.starts_with("Concurrent edits:") | ||
| 42 | { | ||
| 43 | revision.text_runs(oid)?; | ||
| 44 | found.push(View { | ||
| 45 | space: sid, | ||
| 46 | object: oid, | ||
| 47 | revision: rid, | ||
| 48 | text: text.clone(), | ||
| 49 | }); | ||
| 50 | } | ||
| 51 | } | ||
| 52 | } | ||
| 53 | if found.len() != 1 { | ||
| 54 | return Err("Expected one concurrent-edit paragraph".into()); | ||
| 55 | } | ||
| 56 | Ok(found.pop().unwrap()) | ||
| 57 | } | ||
crates/notebook/src/assets.rs created+134| ... | @@ -0,0 +1,134 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::FileDataReference; | ||
| 3 | use rusqlite::OptionalExtension; | ||
| 4 | use sha2::{Digest, Sha256}; | ||
| 5 | |||
| 6 | impl Replica { | ||
| 7 | /// Reads a previously downloaded external payload without network access. | ||
| 8 | /// 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>>> { | ||
| 10 | let key = key(filename)?; | ||
| 11 | let connection = self | ||
| 12 | .connection | ||
| 13 | .lock() | ||
| 14 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 15 | cached(&connection, &key, limit) | ||
| 16 | } | ||
| 17 | |||
| 18 | /// Fetches a declared external payload and durably retains it without changing the edit queue. | ||
| 19 | /// Different bytes for an already cached identity return `AssetChanged`, preserving the cache. | ||
| 20 | /// Network I/O does not hold the cache mutex; a stale reference fails before local publication. | ||
| 21 | pub fn fetch_asset( | ||
| 22 | &self, | ||
| 23 | source: &mut impl crate::discover::Source, | ||
| 24 | section: &str, | ||
| 25 | filename: &str, | ||
| 26 | limit: usize, | ||
| 27 | ) -> Result<Vec<u8>> { | ||
| 28 | let key = key(filename)?; | ||
| 29 | { | ||
| 30 | let connection = self | ||
| 31 | .connection | ||
| 32 | .lock() | ||
| 33 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 34 | if !referenced(&connection, &key)? { | ||
| 35 | return Err(io::Error::new( | ||
| 36 | io::ErrorKind::InvalidInput, | ||
| 37 | "The retained document images do not reference this external payload", | ||
| 38 | ) | ||
| 39 | .into()); | ||
| 40 | } | ||
| 41 | } | ||
| 42 | let bytes = crate::discover::read_external_asset(source, section, filename, limit)?; | ||
| 43 | let mut connection = self | ||
| 44 | .connection | ||
| 45 | .lock() | ||
| 46 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 47 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 48 | if !referenced(&transaction, &key)? { | ||
| 49 | return Err(io::Error::new( | ||
| 50 | io::ErrorKind::ResourceBusy, | ||
| 51 | "The external payload reference changed during download", | ||
| 52 | ) | ||
| 53 | .into()); | ||
| 54 | } | ||
| 55 | let previous = match cached(&transaction, &key, bytes.len()) { | ||
| 56 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::FileTooLarge => { | ||
| 57 | return Err(Error::AssetChanged); | ||
| 58 | } | ||
| 59 | other => other?, | ||
| 60 | }; | ||
| 61 | if let Some(previous) = previous { | ||
| 62 | if previous != bytes { | ||
| 63 | return Err(Error::AssetChanged); | ||
| 64 | } | ||
| 65 | } else { | ||
| 66 | transaction.execute( | ||
| 67 | "INSERT INTO assets(name,data,sha256) VALUES (?1,?2,?3)", | ||
| 68 | params![key, &bytes, &Sha256::digest(&bytes)[..]], | ||
| 69 | )?; | ||
| 70 | } | ||
| 71 | transaction.commit()?; | ||
| 72 | Ok(bytes) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | pub(crate) fn key(filename: &str) -> Result<String> { | ||
| 77 | format!("<file>{filename}").parse::<FileDataReference>()?; | ||
| 78 | Ok(filename.to_ascii_lowercase()) | ||
| 79 | } | ||
| 80 | |||
| 81 | fn referenced(connection: &Connection, key: &str) -> Result<bool> { | ||
| 82 | for column in ["working", "base"] { | ||
| 83 | let image: Vec<u8> = connection.query_row( | ||
| 84 | &format!("SELECT {column} FROM replica WHERE id=1"), | ||
| 85 | [], | ||
| 86 | |row| row.get(0), | ||
| 87 | )?; | ||
| 88 | let store = Store::parse(&image)?; | ||
| 89 | let index = RevisionIndex::parse(&store)?; | ||
| 90 | let document = Document::parse(&index)?; | ||
| 91 | if document.spaces.values().flat_map(|space| space.revisions.values()) | ||
| 92 | .flat_map(|revision| revision.nodes.values()).any(|node| { | ||
| 93 | matches!(&node.kind, Kind::File { reference: FileDataReference::External(name), .. } if name.eq_ignore_ascii_case(key)) | ||
| 94 | }) { | ||
| 95 | return Ok(true); | ||
| 96 | } | ||
| 97 | } | ||
| 98 | Ok(false) | ||
| 99 | } | ||
| 100 | |||
| 101 | pub(crate) fn cached(connection: &Connection, key: &str, limit: usize) -> Result<Option<Vec<u8>>> { | ||
| 102 | let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; | ||
| 103 | if version < 5 { | ||
| 104 | return Ok(None); | ||
| 105 | } | ||
| 106 | let length: Option<i64> = connection | ||
| 107 | .query_row( | ||
| 108 | "SELECT length(data) FROM assets WHERE name=?1", | ||
| 109 | [key], | ||
| 110 | |row| row.get(0), | ||
| 111 | ) | ||
| 112 | .optional()?; | ||
| 113 | let Some(length) = length else { | ||
| 114 | return Ok(None); | ||
| 115 | }; | ||
| 116 | let length = | ||
| 117 | usize::try_from(length).map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?; | ||
| 118 | if length > limit { | ||
| 119 | return Err(io::Error::from(io::ErrorKind::FileTooLarge).into()); | ||
| 120 | } | ||
| 121 | let (bytes, expected): (Vec<u8>, Vec<u8>) = connection.query_row( | ||
| 122 | "SELECT data,sha256 FROM assets WHERE name=?1", | ||
| 123 | [key], | ||
| 124 | |row| Ok((row.get(0)?, row.get(1)?)), | ||
| 125 | )?; | ||
| 126 | if bytes.len() != length || Sha256::digest(&bytes)[..] != expected { | ||
| 127 | return Err(io::Error::new( | ||
| 128 | io::ErrorKind::InvalidData, | ||
| 129 | "Cached external payload checksum mismatch", | ||
| 130 | ) | ||
| 131 | .into()); | ||
| 132 | } | ||
| 133 | Ok(Some(bytes)) | ||
| 134 | } | ||
crates/notebook/src/bin/onestore-diagnostic.rs created+126| ... | @@ -0,0 +1,126 @@ | ||
| 1 | use onestore::{ExGuid, Insertion, PreparedEdit, TextAttribute}; | ||
| 2 | use serde::Deserialize; | ||
| 3 | use serde_json::{Value, json}; | ||
| 4 | use std::{ | ||
| 5 | env, fs, | ||
| 6 | io::{self, Write}, | ||
| 7 | }; | ||
| 8 | |||
| 9 | #[derive(Deserialize)] | ||
| 10 | #[serde(deny_unknown_fields)] | ||
| 11 | struct Edit { | ||
| 12 | space: ExGuid, | ||
| 13 | object: ExGuid, | ||
| 14 | action: Action, | ||
| 15 | } | ||
| 16 | |||
| 17 | #[derive(Deserialize)] | ||
| 18 | #[serde(tag = "type", deny_unknown_fields)] | ||
| 19 | enum Action { | ||
| 20 | Text { | ||
| 21 | start: u32, | ||
| 22 | end: u32, | ||
| 23 | replacement: String, | ||
| 24 | }, | ||
| 25 | Format { | ||
| 26 | start: u32, | ||
| 27 | end: u32, | ||
| 28 | attributes: Vec<TextAttribute>, | ||
| 29 | }, | ||
| 30 | Paragraph { | ||
| 31 | before: Option<ExGuid>, | ||
| 32 | text: String, | ||
| 33 | author: String, | ||
| 34 | }, | ||
| 35 | Outline { | ||
| 36 | x: f32, | ||
| 37 | y: f32, | ||
| 38 | text: String, | ||
| 39 | author: String, | ||
| 40 | }, | ||
| 41 | } | ||
| 42 | |||
| 43 | fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>> { | ||
| 44 | if let [mode, root] = args | ||
| 45 | && mode == "catalog" | ||
| 46 | { | ||
| 47 | let catalog = notebook::discover::discover( | ||
| 48 | &mut notebook::discover::Local::open(root)?, | ||
| 49 | notebook::discover::Limits { | ||
| 50 | entries: 100_000, | ||
| 51 | bytes_per_file: 256 * 1024 * 1024, | ||
| 52 | depth: 64, | ||
| 53 | }, | ||
| 54 | )?; | ||
| 55 | return Ok(json!({"ok": true, "catalog": catalog})); | ||
| 56 | } | ||
| 57 | if args.len() != 3 | ||
| 58 | || !["snapshot", "check", "commit"] | ||
| 59 | .iter() | ||
| 60 | .any(|mode| args[0] == *mode) | ||
| 61 | { | ||
| 62 | return Err("Usage: onestore-diagnostic catalog ROOT | snapshot FILE NEW_SNAPSHOT | check SNAPSHOT - | commit FILE SNAPSHOT; edits arrive as JSON on stdin".into()); | ||
| 63 | } | ||
| 64 | if args[0] == "snapshot" { | ||
| 65 | let bytes = onestore::read_file(&args[1])?; | ||
| 66 | let mut file = fs::OpenOptions::new() | ||
| 67 | .write(true) | ||
| 68 | .create_new(true) | ||
| 69 | .open(&args[2])?; | ||
| 70 | file.write_all(&bytes)?; | ||
| 71 | file.sync_all()?; | ||
| 72 | return Ok(json!({"ok": true, "bytes": bytes.len()})); | ||
| 73 | } | ||
| 74 | let check = args[0] == "check"; | ||
| 75 | if check && args[2] != "-" { | ||
| 76 | return Err("The check command requires '-' as its final argument".into()); | ||
| 77 | } | ||
| 78 | let bytes = fs::read(if check { &args[1] } else { &args[2] })?; | ||
| 79 | let edit: Edit = serde_json::from_reader(io::stdin().lock())?; | ||
| 80 | let sid = edit.space; | ||
| 81 | let oid = edit.object; | ||
| 82 | let prepared = match edit.action { | ||
| 83 | Action::Text { | ||
| 84 | start, | ||
| 85 | end, | ||
| 86 | replacement, | ||
| 87 | } => PreparedEdit::text(&bytes, sid, oid, start..end, &replacement)?, | ||
| 88 | Action::Format { | ||
| 89 | start, | ||
| 90 | end, | ||
| 91 | attributes, | ||
| 92 | } => PreparedEdit::format(&bytes, sid, oid, start..end, &attributes)?, | ||
| 93 | Action::Paragraph { | ||
| 94 | before, | ||
| 95 | text, | ||
| 96 | author, | ||
| 97 | } => PreparedEdit::insert( | ||
| 98 | &bytes, | ||
| 99 | sid, | ||
| 100 | &Insertion::paragraph(oid, before, &text, &author)?, | ||
| 101 | )?, | ||
| 102 | Action::Outline { x, y, text, author } => { | ||
| 103 | PreparedEdit::insert(&bytes, sid, &Insertion::outline(oid, x, y, &text, &author)?)? | ||
| 104 | } | ||
| 105 | }; | ||
| 106 | if check { | ||
| 107 | return Ok(json!({"ok": true})); | ||
| 108 | } | ||
| 109 | Ok(match prepared.commit_file(&args[1]) { | ||
| 110 | Ok(()) => json!({"ok": true, "state": "Committed"}), | ||
| 111 | Err(error) => json!({"ok": false, "state": format!("{:?}", error.state), | ||
| 112 | "kind": format!("{:?}", error.error.kind()), "error": error.error.to_string()}), | ||
| 113 | }) | ||
| 114 | } | ||
| 115 | |||
| 116 | fn main() { | ||
| 117 | let args: Vec<_> = env::args_os().skip(1).collect(); | ||
| 118 | let result = run(&args).unwrap_or_else(|error| { | ||
| 119 | let mut result = json!({"ok": false, "kind": "Input", "error": error.to_string()}); | ||
| 120 | if args.first().is_some_and(|mode| mode == "commit") { | ||
| 121 | result["state"] = json!("NotCommitted"); | ||
| 122 | } | ||
| 123 | result | ||
| 124 | }); | ||
| 125 | println!("{result}"); | ||
| 126 | } | ||
crates/notebook/src/discover.rs created+412| ... | @@ -0,0 +1,412 @@ | ||
| 1 | //! Read-only notebook discovery over a caller-supplied root. | ||
| 2 | |||
| 3 | use onestore::{ | ||
| 4 | FileType, RevisionIndex, Store, | ||
| 5 | document::{Document, Kind}, | ||
| 6 | }; | ||
| 7 | use serde::Serialize; | ||
| 8 | use std::{ | ||
| 9 | collections::{BTreeMap, BTreeSet}, | ||
| 10 | io, | ||
| 11 | }; | ||
| 12 | |||
| 13 | mod source; | ||
| 14 | pub use source::Local; | ||
| 15 | #[cfg(feature = "smb")] | ||
| 16 | pub use source::Smb; | ||
| 17 | |||
| 18 | #[derive(Debug, Serialize)] | ||
| 19 | pub struct Section { | ||
| 20 | pub path: String, | ||
| 21 | /// Header.guidFile, also used by FileIdentityGuid in a parent TOC. | ||
| 22 | pub file_id: [u8; 16], | ||
| 23 | pub state: SectionState, | ||
| 24 | } | ||
| 25 | |||
| 26 | #[derive(Debug, Serialize)] | ||
| 27 | pub enum SectionState { | ||
| 28 | Readable { | ||
| 29 | /// An explicit SectionDisplayName; otherwise use the current filename without its extension. | ||
| 30 | name: Option<String>, | ||
| 31 | }, | ||
| 32 | Locked, | ||
| 33 | Unreadable(onestore::Error), | ||
| 34 | } | ||
| 35 | |||
| 36 | #[derive(Debug, Serialize)] | ||
| 37 | pub struct Folder { | ||
| 38 | pub path: String, | ||
| 39 | pub toc: Option<Toc>, | ||
| 40 | pub sections: Vec<Section>, | ||
| 41 | pub groups: Vec<Folder>, | ||
| 42 | } | ||
| 43 | |||
| 44 | #[derive(Debug, Serialize)] | ||
| 45 | pub struct Toc { | ||
| 46 | pub filename: String, | ||
| 47 | pub file_id: [u8; 16], | ||
| 48 | /// Stale or unavailable TOC references; these are not inferred active sections. | ||
| 49 | pub unresolved: Vec<TocReference>, | ||
| 50 | } | ||
| 51 | |||
| 52 | #[derive(Debug, Serialize)] | ||
| 53 | pub struct TocReference { | ||
| 54 | /// Native TOCs can retain an identity after removing its cached filename. | ||
| 55 | pub filename: Option<String>, | ||
| 56 | pub file: [u8; 16], | ||
| 57 | pub order: u32, | ||
| 58 | } | ||
| 59 | |||
| 60 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | ||
| 61 | pub enum EntryKind { | ||
| 62 | File, | ||
| 63 | Directory, | ||
| 64 | Other, | ||
| 65 | } | ||
| 66 | |||
| 67 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] | ||
| 68 | pub struct Entry { | ||
| 69 | pub name: String, | ||
| 70 | pub kind: EntryKind, | ||
| 71 | } | ||
| 72 | |||
| 73 | /// Rooted file access. Paths are relative, UTF-8, and use `/` between components. | ||
| 74 | pub trait Source { | ||
| 75 | /// Return the complete immediate directory or an error, never a truncated success. | ||
| 76 | fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>>; | ||
| 77 | /// Return a consistent file snapshot, rejecting images larger than the byte limit. | ||
| 78 | fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>>; | ||
| 79 | /// Reads an external payload with the same completeness and size guarantees. | ||
| 80 | fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 81 | self.read(path, limit) | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | /// Reads an external file-data reference from the section's sibling `_onefiles` folder. | ||
| 86 | /// `NotFound` in `Error::Io` is distinct from a successfully read zero-byte payload. | ||
| 87 | pub fn read_external_asset( | ||
| 88 | source: &mut impl Source, | ||
| 89 | section: &str, | ||
| 90 | filename: &str, | ||
| 91 | limit: usize, | ||
| 92 | ) -> Result<Vec<u8>, Error> { | ||
| 93 | let (stem, extension) = section.rsplit_once('.').ok_or_else(|| Error::Entry { | ||
| 94 | path: section.into(), | ||
| 95 | })?; | ||
| 96 | if !extension.eq_ignore_ascii_case("one") | ||
| 97 | || !section.split('/').all(component) | ||
| 98 | || stem.ends_with('/') | ||
| 99 | || stem.is_empty() | ||
| 100 | { | ||
| 101 | return Err(Error::Entry { | ||
| 102 | path: section.into(), | ||
| 103 | }); | ||
| 104 | } | ||
| 105 | format!("<file>{filename}") | ||
| 106 | .parse::<onestore::FileDataReference>() | ||
| 107 | .map_err(|_| Error::Entry { | ||
| 108 | path: filename.into(), | ||
| 109 | })?; | ||
| 110 | let path = format!("{stem}_onefiles/{filename}"); | ||
| 111 | let bytes = source.read_asset(&path, limit).map_err(|error| Error::Io { | ||
| 112 | path: path.clone(), | ||
| 113 | error, | ||
| 114 | })?; | ||
| 115 | if bytes.len() > limit { | ||
| 116 | return Err(Error::Limit { path }); | ||
| 117 | } | ||
| 118 | Ok(bytes) | ||
| 119 | } | ||
| 120 | |||
| 121 | pub struct Limits { | ||
| 122 | pub entries: usize, | ||
| 123 | pub bytes_per_file: usize, | ||
| 124 | pub depth: usize, | ||
| 125 | } | ||
| 126 | |||
| 127 | #[derive(Debug, thiserror::Error)] | ||
| 128 | pub enum Error { | ||
| 129 | #[error("Cannot read {path}: {error}")] | ||
| 130 | Io { | ||
| 131 | path: String, | ||
| 132 | #[source] | ||
| 133 | error: io::Error, | ||
| 134 | }, | ||
| 135 | #[error("Invalid notebook file {path}: {error}")] | ||
| 136 | Document { | ||
| 137 | path: String, | ||
| 138 | #[source] | ||
| 139 | error: onestore::Error, | ||
| 140 | }, | ||
| 141 | #[error("Discovery limit exceeded at {path}")] | ||
| 142 | Limit { path: String }, | ||
| 143 | #[error("Directory changed during discovery: {path}")] | ||
| 144 | Changed { path: String }, | ||
| 145 | #[error("Invalid or unsupported directory entry: {path}")] | ||
| 146 | Entry { path: String }, | ||
| 147 | #[error("File identity occurs at both {first} and {second}")] | ||
| 148 | DuplicateIdentity { | ||
| 149 | file: [u8; 16], | ||
| 150 | first: String, | ||
| 151 | second: String, | ||
| 152 | }, | ||
| 153 | } | ||
| 154 | |||
| 155 | /// Discovers rooted notebook topology within caller-specified work and size limits. | ||
| 156 | /// Reserved `_onefiles` directories contain payloads, not section groups. | ||
| 157 | pub fn discover(source: &mut impl Source, limits: Limits) -> Result<Folder, Error> { | ||
| 158 | let mut remaining = limits.entries; | ||
| 159 | let mut identities = BTreeMap::new(); | ||
| 160 | scan(source, "", &limits, 0, &mut remaining, &mut identities) | ||
| 161 | } | ||
| 162 | |||
| 163 | fn scan( | ||
| 164 | source: &mut impl Source, | ||
| 165 | path: &str, | ||
| 166 | limits: &Limits, | ||
| 167 | depth: usize, | ||
| 168 | remaining: &mut usize, | ||
| 169 | identities: &mut BTreeMap<[u8; 16], String>, | ||
| 170 | ) -> Result<Folder, Error> { | ||
| 171 | if depth > limits.depth { | ||
| 172 | return Err(Error::Limit { path: path.into() }); | ||
| 173 | } | ||
| 174 | let mut listing = source | ||
| 175 | .entries(path, *remaining) | ||
| 176 | .map_err(|error| Error::Io { | ||
| 177 | path: path.into(), | ||
| 178 | error, | ||
| 179 | })?; | ||
| 180 | *remaining = remaining | ||
| 181 | .checked_sub(listing.len()) | ||
| 182 | .ok_or_else(|| Error::Limit { path: path.into() })?; | ||
| 183 | listing.sort(); | ||
| 184 | let mut names = BTreeSet::new(); | ||
| 185 | for entry in &listing { | ||
| 186 | if !component(&entry.name) || !names.insert(&entry.name) { | ||
| 187 | return Err(Error::Entry { | ||
| 188 | path: join(path, &entry.name), | ||
| 189 | }); | ||
| 190 | } | ||
| 191 | } | ||
| 192 | let mut result = Folder { | ||
| 193 | path: path.into(), | ||
| 194 | toc: None, | ||
| 195 | sections: Vec::new(), | ||
| 196 | groups: Vec::new(), | ||
| 197 | }; | ||
| 198 | for entry in &listing { | ||
| 199 | let child = join(path, &entry.name); | ||
| 200 | if entry.kind == EntryKind::Directory { | ||
| 201 | if entry.name.to_ascii_lowercase().ends_with("_onefiles") { | ||
| 202 | continue; | ||
| 203 | } | ||
| 204 | result.groups.push(scan( | ||
| 205 | source, | ||
| 206 | &child, | ||
| 207 | limits, | ||
| 208 | depth + 1, | ||
| 209 | remaining, | ||
| 210 | identities, | ||
| 211 | )?); | ||
| 212 | continue; | ||
| 213 | } | ||
| 214 | let lower = entry.name.to_ascii_lowercase(); | ||
| 215 | let expected = if lower.ends_with(".one") { | ||
| 216 | FileType::Section | ||
| 217 | } else if lower.ends_with(".onetoc2") { | ||
| 218 | FileType::TableOfContents | ||
| 219 | } else { | ||
| 220 | continue; | ||
| 221 | }; | ||
| 222 | if entry.kind != EntryKind::File | ||
| 223 | || (expected == FileType::TableOfContents && result.toc.is_some()) | ||
| 224 | { | ||
| 225 | return Err(Error::Entry { path: child }); | ||
| 226 | } | ||
| 227 | let bytes = source | ||
| 228 | .read(&child, limits.bytes_per_file) | ||
| 229 | .map_err(|error| Error::Io { | ||
| 230 | path: child.clone(), | ||
| 231 | error, | ||
| 232 | })?; | ||
| 233 | if bytes.len() > limits.bytes_per_file { | ||
| 234 | return Err(Error::Limit { path: child }); | ||
| 235 | } | ||
| 236 | let store = Store::parse(&bytes).map_err(|error| Error::Document { | ||
| 237 | path: child.clone(), | ||
| 238 | error, | ||
| 239 | })?; | ||
| 240 | if store.header.file_type != expected || !store.checksum_mismatches.is_empty() { | ||
| 241 | return Err(Error::Document { | ||
| 242 | path: child, | ||
| 243 | error: onestore::Error { | ||
| 244 | offset: 0, | ||
| 245 | message: "Unexpected file type or checksum mismatch", | ||
| 246 | }, | ||
| 247 | }); | ||
| 248 | } | ||
| 249 | let file_id = store.header.file_id; | ||
| 250 | let parsed = (|| { | ||
| 251 | let index = RevisionIndex::parse(&store)?; | ||
| 252 | let document = Document::parse(&index)?; | ||
| 253 | let revision = document.active(document.root)?; | ||
| 254 | if expected == FileType::Section { | ||
| 255 | if revision | ||
| 256 | .roots | ||
| 257 | .get(&1) | ||
| 258 | .and_then(|id| revision.nodes.get(id)) | ||
| 259 | .is_some_and(|node| matches!(node.kind, Kind::Encrypted { .. })) | ||
| 260 | { | ||
| 261 | result.sections.push(Section { | ||
| 262 | path: child.clone(), | ||
| 263 | file_id, | ||
| 264 | state: SectionState::Locked, | ||
| 265 | }); | ||
| 266 | return Ok(()); | ||
| 267 | } | ||
| 268 | index.validate_current()?; | ||
| 269 | document.pages()?; | ||
| 270 | let name = revision | ||
| 271 | .roots | ||
| 272 | .get(&2) | ||
| 273 | .and_then(|id| revision.nodes.get(id)) | ||
| 274 | .and_then(|node| match &node.kind { | ||
| 275 | Kind::SectionMetadata { name, .. } => name.clone(), | ||
| 276 | _ => None, | ||
| 277 | }); | ||
| 278 | result.sections.push(Section { | ||
| 279 | path: child.clone(), | ||
| 280 | file_id, | ||
| 281 | state: SectionState::Readable { name }, | ||
| 282 | }); | ||
| 283 | } else { | ||
| 284 | index.validate_current()?; | ||
| 285 | let node = revision.roots.get(&1).and_then(|id| revision.nodes.get(id)); | ||
| 286 | let Some(Kind::Toc { entries, .. }) = node.map(|node| &node.kind) else { | ||
| 287 | return Err(onestore::Error { | ||
| 288 | offset: 0, | ||
| 289 | message: "Missing notebook TOC root", | ||
| 290 | }); | ||
| 291 | }; | ||
| 292 | let mut seen = BTreeSet::new(); | ||
| 293 | let mut unresolved = Vec::new(); | ||
| 294 | for id in entries { | ||
| 295 | let Some(Kind::Toc { | ||
| 296 | filename, | ||
| 297 | identity: Some(file), | ||
| 298 | order: Some(order), | ||
| 299 | .. | ||
| 300 | }) = revision.nodes.get(id).map(|node| &node.kind) | ||
| 301 | else { | ||
| 302 | return Err(onestore::Error { | ||
| 303 | offset: 0, | ||
| 304 | message: "Incomplete notebook TOC reference", | ||
| 305 | }); | ||
| 306 | }; | ||
| 307 | if filename.as_deref().is_some_and(|name| !component(name)) | ||
| 308 | || !seen.insert(*file) | ||
| 309 | { | ||
| 310 | return Err(onestore::Error { | ||
| 311 | offset: 0, | ||
| 312 | message: "Invalid or duplicated notebook TOC reference", | ||
| 313 | }); | ||
| 314 | } | ||
| 315 | unresolved.push(TocReference { | ||
| 316 | filename: filename.clone(), | ||
| 317 | file: *file, | ||
| 318 | order: *order, | ||
| 319 | }); | ||
| 320 | } | ||
| 321 | result.toc = Some(Toc { | ||
| 322 | filename: entry.name.clone(), | ||
| 323 | file_id, | ||
| 324 | unresolved, | ||
| 325 | }); | ||
| 326 | } | ||
| 327 | Ok(()) | ||
| 328 | })(); | ||
| 329 | if let Err(error) = parsed { | ||
| 330 | if expected == FileType::Section { | ||
| 331 | result.sections.push(Section { | ||
| 332 | path: child.clone(), | ||
| 333 | file_id, | ||
| 334 | state: SectionState::Unreadable(error), | ||
| 335 | }); | ||
| 336 | } else { | ||
| 337 | return Err(Error::Document { path: child, error }); | ||
| 338 | } | ||
| 339 | } | ||
| 340 | if let Some(first) = identities.insert(file_id, child.clone()) { | ||
| 341 | return Err(Error::DuplicateIdentity { | ||
| 342 | file: file_id, | ||
| 343 | first, | ||
| 344 | second: child, | ||
| 345 | }); | ||
| 346 | } | ||
| 347 | } | ||
| 348 | let order: BTreeMap<_, _> = result | ||
| 349 | .toc | ||
| 350 | .iter() | ||
| 351 | .flat_map(|toc| &toc.unresolved) | ||
| 352 | .map(|entry| (entry.file, entry.order)) | ||
| 353 | .collect(); | ||
| 354 | result.sections.sort_by(|a, b| { | ||
| 355 | (order.get(&a.file_id).copied().unwrap_or(u32::MAX), &a.path) | ||
| 356 | .cmp(&(order.get(&b.file_id).copied().unwrap_or(u32::MAX), &b.path)) | ||
| 357 | }); | ||
| 358 | result.groups.sort_by(|a, b| { | ||
| 359 | ( | ||
| 360 | a.toc | ||
| 361 | .as_ref() | ||
| 362 | .and_then(|toc| order.get(&toc.file_id).copied()) | ||
| 363 | .unwrap_or(u32::MAX), | ||
| 364 | &a.path, | ||
| 365 | ) | ||
| 366 | .cmp(&( | ||
| 367 | b.toc | ||
| 368 | .as_ref() | ||
| 369 | .and_then(|toc| order.get(&toc.file_id).copied()) | ||
| 370 | .unwrap_or(u32::MAX), | ||
| 371 | &b.path, | ||
| 372 | )) | ||
| 373 | }); | ||
| 374 | let present: BTreeSet<_> = result | ||
| 375 | .sections | ||
| 376 | .iter() | ||
| 377 | .map(|section| section.file_id) | ||
| 378 | .chain( | ||
| 379 | result | ||
| 380 | .groups | ||
| 381 | .iter() | ||
| 382 | .filter_map(|group| group.toc.as_ref().map(|toc| toc.file_id)), | ||
| 383 | ) | ||
| 384 | .collect(); | ||
| 385 | if let Some(toc) = &mut result.toc { | ||
| 386 | toc.unresolved | ||
| 387 | .retain(|entry| !present.contains(&entry.file)); | ||
| 388 | } | ||
| 389 | let mut observed = source | ||
| 390 | .entries(path, listing.len()) | ||
| 391 | .map_err(|error| Error::Io { | ||
| 392 | path: path.into(), | ||
| 393 | error, | ||
| 394 | })?; | ||
| 395 | observed.sort(); | ||
| 396 | if observed != listing { | ||
| 397 | return Err(Error::Changed { path: path.into() }); | ||
| 398 | } | ||
| 399 | Ok(result) | ||
| 400 | } | ||
| 401 | |||
| 402 | fn component(name: &str) -> bool { | ||
| 403 | !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) | ||
| 404 | } | ||
| 405 | |||
| 406 | fn join(parent: &str, name: &str) -> String { | ||
| 407 | if parent.is_empty() { | ||
| 408 | name.into() | ||
| 409 | } else { | ||
| 410 | format!("{parent}/{name}") | ||
| 411 | } | ||
| 412 | } | ||
crates/notebook/src/discover/source.rs created+121| ... | @@ -0,0 +1,121 @@ | ||
| 1 | use super::{Entry, EntryKind, Source, component}; | ||
| 2 | use std::{ | ||
| 3 | io, | ||
| 4 | path::{Path, PathBuf}, | ||
| 5 | }; | ||
| 6 | |||
| 7 | /// A canonical local root; symbolic-link entries are not traversed. | ||
| 8 | pub struct Local { | ||
| 9 | root: PathBuf, | ||
| 10 | } | ||
| 11 | |||
| 12 | impl Local { | ||
| 13 | pub fn open(root: impl AsRef<Path>) -> io::Result<Self> { | ||
| 14 | let root = root.as_ref().canonicalize()?; | ||
| 15 | if !root.is_dir() { | ||
| 16 | return Err(io::ErrorKind::NotADirectory.into()); | ||
| 17 | } | ||
| 18 | Ok(Self { root }) | ||
| 19 | } | ||
| 20 | |||
| 21 | fn path(&self, relative: &str) -> io::Result<PathBuf> { | ||
| 22 | if !relative.is_empty() && !relative.split('/').all(component) { | ||
| 23 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 24 | } | ||
| 25 | let path = self.root.join(relative).canonicalize()?; | ||
| 26 | if !path.starts_with(&self.root) { | ||
| 27 | return Err(io::ErrorKind::PermissionDenied.into()); | ||
| 28 | } | ||
| 29 | Ok(path) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | impl Source for Local { | ||
| 34 | fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> { | ||
| 35 | let mut entries = Vec::new(); | ||
| 36 | for entry in std::fs::read_dir(self.path(path)?)? { | ||
| 37 | let entry = entry?; | ||
| 38 | if entries.len() == limit { | ||
| 39 | return Err(io::ErrorKind::FileTooLarge.into()); | ||
| 40 | } | ||
| 41 | let name = entry | ||
| 42 | .file_name() | ||
| 43 | .into_string() | ||
| 44 | .map_err(|_| io::ErrorKind::InvalidData)?; | ||
| 45 | let kind = entry.file_type()?; | ||
| 46 | entries.push(Entry { | ||
| 47 | name, | ||
| 48 | kind: if kind.is_dir() { | ||
| 49 | EntryKind::Directory | ||
| 50 | } else if kind.is_file() { | ||
| 51 | EntryKind::File | ||
| 52 | } else { | ||
| 53 | EntryKind::Other | ||
| 54 | }, | ||
| 55 | }); | ||
| 56 | } | ||
| 57 | Ok(entries) | ||
| 58 | } | ||
| 59 | |||
| 60 | fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 61 | onestore::read_file_limited(self.path(path)?, limit) | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | #[cfg(feature = "smb")] | ||
| 66 | /// A share-relative root on an existing blocking SMB connection. | ||
| 67 | pub struct Smb<'a> { | ||
| 68 | client: &'a crate::smb::Client, | ||
| 69 | root: String, | ||
| 70 | } | ||
| 71 | |||
| 72 | #[cfg(feature = "smb")] | ||
| 73 | impl<'a> Smb<'a> { | ||
| 74 | pub fn new(client: &'a crate::smb::Client, root: &str) -> io::Result<Self> { | ||
| 75 | let root = root.replace('\\', "/"); | ||
| 76 | if !root.is_empty() && !root.split('/').all(component) { | ||
| 77 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 78 | } | ||
| 79 | Ok(Self { client, root }) | ||
| 80 | } | ||
| 81 | |||
| 82 | fn path(&self, relative: &str) -> io::Result<String> { | ||
| 83 | if !relative.is_empty() && !relative.split('/').all(component) { | ||
| 84 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 85 | } | ||
| 86 | Ok(if relative.is_empty() { | ||
| 87 | self.root.clone() | ||
| 88 | } else { | ||
| 89 | super::join(&self.root, relative) | ||
| 90 | }) | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | #[cfg(feature = "smb")] | ||
| 95 | impl Source for Smb<'_> { | ||
| 96 | fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> { | ||
| 97 | Ok(self | ||
| 98 | .client | ||
| 99 | .read_dir(&self.path(path)?, limit)? | ||
| 100 | .into_iter() | ||
| 101 | .map(|entry| Entry { | ||
| 102 | name: entry.name, | ||
| 103 | kind: if entry.attributes & 0x400 != 0 { | ||
| 104 | EntryKind::Other | ||
| 105 | } else if entry.attributes & 0x10 != 0 { | ||
| 106 | EntryKind::Directory | ||
| 107 | } else { | ||
| 108 | EntryKind::File | ||
| 109 | }, | ||
| 110 | }) | ||
| 111 | .collect()) | ||
| 112 | } | ||
| 113 | |||
| 114 | fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 115 | self.client.read_storage(&self.path(path)?, limit) | ||
| 116 | } | ||
| 117 | |||
| 118 | fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 119 | self.client.read_asset(&self.path(path)?, limit) | ||
| 120 | } | ||
| 121 | } | ||
crates/notebook/src/formatting.rs created+203| ... | @@ -0,0 +1,203 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::TextAttribute; | ||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | use serde_json::{Value, json}; | ||
| 5 | use std::collections::BTreeMap; | ||
| 6 | |||
| 7 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 8 | #[serde(deny_unknown_fields)] | ||
| 9 | struct Span { | ||
| 10 | end: u32, | ||
| 11 | values: Vec<Value>, | ||
| 12 | } | ||
| 13 | |||
| 14 | /// A formatting intent and the text/attribute values observed before local publication. | ||
| 15 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 16 | #[serde(deny_unknown_fields)] | ||
| 17 | pub struct FormatEdit { | ||
| 18 | pub object: ExGuid, | ||
| 19 | pub before: String, | ||
| 20 | pub range: Range<u32>, | ||
| 21 | pub attributes: Vec<TextAttribute>, | ||
| 22 | observed: Vec<Span>, | ||
| 23 | } | ||
| 24 | |||
| 25 | fn fields(attributes: &[TextAttribute]) -> BTreeMap<&'static str, Value> { | ||
| 26 | let mut fields = BTreeMap::new(); | ||
| 27 | for attribute in attributes { | ||
| 28 | let (name, value) = match attribute { | ||
| 29 | TextAttribute::Bold(v) => ("bold", json!(v)), | ||
| 30 | TextAttribute::Italic(v) => ("italic", json!(v)), | ||
| 31 | TextAttribute::Underline(v) => ("underline", json!(v)), | ||
| 32 | TextAttribute::Strike(v) => ("strike", json!(v)), | ||
| 33 | TextAttribute::Superscript(v) => { | ||
| 34 | if *v { | ||
| 35 | fields.insert("subscript", json!(false)); | ||
| 36 | } | ||
| 37 | ("superscript", json!(v)) | ||
| 38 | } | ||
| 39 | TextAttribute::Subscript(v) => { | ||
| 40 | if *v { | ||
| 41 | fields.insert("superscript", json!(false)); | ||
| 42 | } | ||
| 43 | ("subscript", json!(v)) | ||
| 44 | } | ||
| 45 | TextAttribute::Font(v) => ("font", json!(v)), | ||
| 46 | TextAttribute::FontSize(v) => ("font_size", json!(v)), | ||
| 47 | TextAttribute::Color(v) | TextAttribute::Highlight(v) => ( | ||
| 48 | if matches!(attribute, TextAttribute::Color(_)) { | ||
| 49 | "color" | ||
| 50 | } else { | ||
| 51 | "highlight" | ||
| 52 | }, | ||
| 53 | json!(v.map_or(0xff000000, |[r, g, b]| u32::from_le_bytes([r, g, b, 0]))), | ||
| 54 | ), | ||
| 55 | }; | ||
| 56 | fields.insert(name, value); | ||
| 57 | } | ||
| 58 | fields | ||
| 59 | } | ||
| 60 | |||
| 61 | fn observe( | ||
| 62 | source: &[u8], | ||
| 63 | space: ExGuid, | ||
| 64 | object: ExGuid, | ||
| 65 | range: Range<u32>, | ||
| 66 | fields: &BTreeMap<&str, Value>, | ||
| 67 | ) -> Result<Vec<Span>> { | ||
| 68 | let store = Store::parse(source)?; | ||
| 69 | let index = RevisionIndex::parse(&store)?; | ||
| 70 | let document = Document::parse(&index)?; | ||
| 71 | let revision = document.active(space)?; | ||
| 72 | let mut spans: Vec<Span> = Vec::new(); | ||
| 73 | let mut start = 0; | ||
| 74 | for run in revision.text_runs(object)? { | ||
| 75 | let end = | ||
| 76 | start + u32::try_from(run.text.encode_utf16().count()).map_err(io::Error::other)?; | ||
| 77 | if (start < range.end && range.start < end) || (start == 0 && end == 0 && range == (0..0)) { | ||
| 78 | let format = serde_json::to_value(run.format).map_err(io::Error::other)?; | ||
| 79 | let values = fields | ||
| 80 | .iter() | ||
| 81 | .map(|(name, desired)| { | ||
| 82 | let value = &format[*name]; | ||
| 83 | if value.is_null() && desired.is_boolean() { | ||
| 84 | json!(false) | ||
| 85 | } else if value.is_null() && matches!(*name, "color" | "highlight") { | ||
| 86 | json!(0xff000000_u32) | ||
| 87 | } else { | ||
| 88 | value.clone() | ||
| 89 | } | ||
| 90 | }) | ||
| 91 | .collect::<Vec<_>>(); | ||
| 92 | let end = end.min(range.end) - range.start; | ||
| 93 | if let Some(last) = spans.last_mut().filter(|s| s.values == values) { | ||
| 94 | last.end = end; | ||
| 95 | } else { | ||
| 96 | spans.push(Span { end, values }); | ||
| 97 | } | ||
| 98 | } | ||
| 99 | start = end; | ||
| 100 | } | ||
| 101 | Ok(spans) | ||
| 102 | } | ||
| 103 | |||
| 104 | impl Replica { | ||
| 105 | /// Durably records a visual-formatting change and its observed attribute values. | ||
| 106 | /// Independent remote attributes can merge; competing values preserve a conflict. | ||
| 107 | pub fn format( | ||
| 108 | &self, | ||
| 109 | source: &[u8], | ||
| 110 | space: ExGuid, | ||
| 111 | object: ExGuid, | ||
| 112 | range: Range<u32>, | ||
| 113 | attributes: &[TextAttribute], | ||
| 114 | ) -> Result<Option<u64>> { | ||
| 115 | let (edit, prepared) = FormatEdit::capture(source, space, object, range, attributes)?; | ||
| 116 | self.record(source, space, Operation::Format(edit), &prepared) | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | impl FormatEdit { | ||
| 121 | pub(crate) fn capture<'a>( | ||
| 122 | source: &'a [u8], | ||
| 123 | space: ExGuid, | ||
| 124 | object: ExGuid, | ||
| 125 | range: Range<u32>, | ||
| 126 | attributes: &[TextAttribute], | ||
| 127 | ) -> Result<(Self, PreparedEdit<'a>)> { | ||
| 128 | let prepared = PreparedEdit::format(source, space, object, range.clone(), attributes)?; | ||
| 129 | let before = paragraph(source, space, object)?.ok_or_else(|| { | ||
| 130 | io::Error::new( | ||
| 131 | io::ErrorKind::InvalidData, | ||
| 132 | "Prepared formatting has no text target", | ||
| 133 | ) | ||
| 134 | })?; | ||
| 135 | let observed = observe(source, space, object, range.clone(), &fields(attributes))?; | ||
| 136 | let edit = Self { | ||
| 137 | object, | ||
| 138 | before, | ||
| 139 | range, | ||
| 140 | attributes: attributes.to_vec(), | ||
| 141 | observed, | ||
| 142 | }; | ||
| 143 | Ok((edit, prepared)) | ||
| 144 | } | ||
| 145 | |||
| 146 | pub(crate) fn prepare<'a>( | ||
| 147 | &self, | ||
| 148 | snapshot: &'a [u8], | ||
| 149 | space: ExGuid, | ||
| 150 | ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> { | ||
| 151 | let Some(text) = paragraph(snapshot, space, self.object)? else { | ||
| 152 | return Ok(Err(ConflictKind::TargetUnavailable)); | ||
| 153 | }; | ||
| 154 | let Some(range) = rebase::rebase(&self.before, &text, self.range.clone()) else { | ||
| 155 | return Ok(Err(ConflictKind::TextChanged)); | ||
| 156 | }; | ||
| 157 | let prepared = match PreparedEdit::format( | ||
| 158 | snapshot, | ||
| 159 | space, | ||
| 160 | self.object, | ||
| 161 | range.clone(), | ||
| 162 | &self.attributes, | ||
| 163 | ) { | ||
| 164 | Ok(prepared) => prepared, | ||
| 165 | Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)), | ||
| 166 | }; | ||
| 167 | let desired = fields(&self.attributes); | ||
| 168 | let observed = observe(snapshot, space, self.object, range.clone(), &desired)?; | ||
| 169 | let wanted: Vec<_> = desired.values().collect(); | ||
| 170 | for spans in [&self.observed, &observed] { | ||
| 171 | if spans.is_empty() | ||
| 172 | || (!range.is_empty() && spans[0].end == 0) | ||
| 173 | || spans.last().unwrap().end != range.end - range.start | ||
| 174 | || spans.windows(2).any(|s| s[0].end >= s[1].end) | ||
| 175 | || spans.iter().any(|s| s.values.len() != wanted.len()) | ||
| 176 | { | ||
| 177 | return Ok(Err(ConflictKind::UnsupportedEdit)); | ||
| 178 | } | ||
| 179 | } | ||
| 180 | let (mut before, mut after) = (0, 0); | ||
| 181 | while before < self.observed.len() && after < observed.len() { | ||
| 182 | let old = &self.observed[before]; | ||
| 183 | let current = &observed[after]; | ||
| 184 | if old | ||
| 185 | .values | ||
| 186 | .iter() | ||
| 187 | .zip(&current.values) | ||
| 188 | .zip(&wanted) | ||
| 189 | .any(|((old, new), wanted)| new != old && new != *wanted) | ||
| 190 | { | ||
| 191 | return Ok(Err(ConflictKind::FormattingChanged)); | ||
| 192 | } | ||
| 193 | let end = old.end.min(current.end); | ||
| 194 | if old.end == end { | ||
| 195 | before += 1; | ||
| 196 | } | ||
| 197 | if current.end == end { | ||
| 198 | after += 1; | ||
| 199 | } | ||
| 200 | } | ||
| 201 | Ok(Ok(prepared)) | ||
| 202 | } | ||
| 203 | } | ||
crates/notebook/src/lib.rs created+434| ... | @@ -0,0 +1,434 @@ | ||
| 1 | #![forbid(unsafe_code)] | ||
| 2 | #![doc = include_str!("../README.md")] | ||
| 3 | |||
| 4 | pub mod discover; | ||
| 5 | #[cfg(feature = "smb")] | ||
| 6 | pub mod smb; | ||
| 7 | |||
| 8 | use onestore::{ | ||
| 9 | ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store, | ||
| 10 | document::{Document, Kind}, | ||
| 11 | }; | ||
| 12 | use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; | ||
| 13 | use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration}; | ||
| 14 | |||
| 15 | mod assets; | ||
| 16 | mod formatting; | ||
| 17 | mod outline; | ||
| 18 | mod pages; | ||
| 19 | mod paragraph; | ||
| 20 | mod rebase; | ||
| 21 | mod recovery; | ||
| 22 | mod schema; | ||
| 23 | mod tree; | ||
| 24 | pub use formatting::FormatEdit; | ||
| 25 | pub use outline::OutlineEdit; | ||
| 26 | pub use pages::PageEdits; | ||
| 27 | pub use paragraph::{JoinEdit, SplitEdit}; | ||
| 28 | pub use recovery::{Recovery, RecoverySummary}; | ||
| 29 | pub use tree::TreeEdit; | ||
| 30 | mod sync; | ||
| 31 | pub use sync::{ConflictKind, EditStatus, Remote}; | ||
| 32 | mod worker; | ||
| 33 | #[cfg(feature = "smb")] | ||
| 34 | pub use smb::SmbRemote; | ||
| 35 | pub use worker::SyncWorker; | ||
| 36 | |||
| 37 | #[derive(Debug, thiserror::Error)] | ||
| 38 | pub enum Error { | ||
| 39 | #[error(transparent)] | ||
| 40 | Database(#[from] rusqlite::Error), | ||
| 41 | #[error(transparent)] | ||
| 42 | Io(#[from] io::Error), | ||
| 43 | #[error(transparent)] | ||
| 44 | Document(#[from] onestore::Error), | ||
| 45 | #[error(transparent)] | ||
| 46 | Remote(#[from] onestore::CommitError), | ||
| 47 | #[error(transparent)] | ||
| 48 | RemoteIo(io::Error), | ||
| 49 | #[error(transparent)] | ||
| 50 | Discovery(#[from] discover::Error), | ||
| 51 | #[error("External payload identity now refers to different bytes")] | ||
| 52 | AssetChanged, | ||
| 53 | } | ||
| 54 | |||
| 55 | type Result<T> = std::result::Result<T, Error>; | ||
| 56 | |||
| 57 | const APPLICATION_ID: u32 = 0x4f4e454f; | ||
| 58 | const SCHEMA_VERSION: u32 = 11; | ||
| 59 | |||
| 60 | /// Text and its observed precondition, retained across cache reopen and rebasing. | ||
| 61 | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] | ||
| 62 | #[serde(deny_unknown_fields)] | ||
| 63 | pub struct TextEdit { | ||
| 64 | pub object: ExGuid, | ||
| 65 | pub before: String, | ||
| 66 | pub range: Range<u32>, | ||
| 67 | pub replacement: String, | ||
| 68 | } | ||
| 69 | |||
| 70 | #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] | ||
| 71 | #[serde(deny_unknown_fields)] | ||
| 72 | pub enum Operation { | ||
| 73 | CreatePage(PageCreation), | ||
| 74 | Pages(PageEdits), | ||
| 75 | Text(TextEdit), | ||
| 76 | Insert(Insertion), | ||
| 77 | Format(FormatEdit), | ||
| 78 | Split(SplitEdit), | ||
| 79 | Join(JoinEdit), | ||
| 80 | Outline(OutlineEdit), | ||
| 81 | Tree(TreeEdit), | ||
| 82 | } | ||
| 83 | |||
| 84 | /// A locally acknowledged intent; its ID remains stable across cache reopen. | ||
| 85 | #[derive(Debug, Clone, PartialEq)] | ||
| 86 | pub struct PendingEdit { | ||
| 87 | pub id: u64, | ||
| 88 | pub space: ExGuid, | ||
| 89 | pub operation: Operation, | ||
| 90 | } | ||
| 91 | |||
| 92 | /// Owns one local cache. Share this handle between threads; a second open fails busy. | ||
| 93 | /// SQLite's exclusive connection retains ownership between local transactions. | ||
| 94 | pub struct Replica { | ||
| 95 | connection: Mutex<Connection>, | ||
| 96 | synchronization: Mutex<()>, | ||
| 97 | worker: Mutex<std::sync::Weak<worker::Signal>>, | ||
| 98 | } | ||
| 99 | |||
| 100 | impl Replica { | ||
| 101 | /// Seeds a new cache from a validated notebook image, refusing any existing path. | ||
| 102 | /// An initialization error preserves the created file for inspection. | ||
| 103 | pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> { | ||
| 104 | validate(source)?; | ||
| 105 | let mut options = OpenOptions::new(); | ||
| 106 | options.read(true).write(true).create_new(true); | ||
| 107 | #[cfg(unix)] | ||
| 108 | { | ||
| 109 | use std::os::unix::fs::OpenOptionsExt; | ||
| 110 | options.mode(0o600); | ||
| 111 | } | ||
| 112 | drop(options.open(path.as_ref())?); | ||
| 113 | Self::connect(path.as_ref(), Some(source)) | ||
| 114 | } | ||
| 115 | |||
| 116 | /// Reopens an existing cache and its durable pending edits without network access. | ||
| 117 | /// Unrecognized databases and unsupported journal modes are rejected without conversion. | ||
| 118 | pub fn open(path: impl AsRef<Path>) -> Result<Self> { | ||
| 119 | Self::connect(path.as_ref(), None) | ||
| 120 | } | ||
| 121 | |||
| 122 | fn connect(path: &Path, source: Option<&[u8]>) -> Result<Self> { | ||
| 123 | let mut connection = cache_connection(path)?; | ||
| 124 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?; | ||
| 125 | let application: u32 = | ||
| 126 | transaction.pragma_query_value(None, "application_id", |row| row.get(0))?; | ||
| 127 | let version: u32 = | ||
| 128 | transaction.pragma_query_value(None, "user_version", |row| row.get(0))?; | ||
| 129 | if let Some(source) = source { | ||
| 130 | let tables: i64 = | ||
| 131 | transaction | ||
| 132 | .query_row("SELECT count(*) FROM sqlite_schema", [], |row| row.get(0))?; | ||
| 133 | if application != 0 || version != 0 || tables != 0 { | ||
| 134 | return Err(io::Error::new( | ||
| 135 | io::ErrorKind::AlreadyExists, | ||
| 136 | "Cache initialization found an existing database", | ||
| 137 | ) | ||
| 138 | .into()); | ||
| 139 | } | ||
| 140 | transaction.pragma_update(None, "application_id", APPLICATION_ID)?; | ||
| 141 | transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?; | ||
| 142 | transaction.execute_batch( | ||
| 143 | " | ||
| 144 | CREATE TABLE replica ( | ||
| 145 | id INTEGER PRIMARY KEY CHECK(id=1), | ||
| 146 | base BLOB NOT NULL, | ||
| 147 | working BLOB NOT NULL | ||
| 148 | ) STRICT; | ||
| 149 | ", | ||
| 150 | )?; | ||
| 151 | schema::create(&transaction)?; | ||
| 152 | transaction.execute("INSERT INTO replica VALUES (1, ?1, ?1)", [source])?; | ||
| 153 | } else { | ||
| 154 | if application != APPLICATION_ID || !(1..=SCHEMA_VERSION).contains(&version) { | ||
| 155 | return Err(io::Error::new( | ||
| 156 | io::ErrorKind::InvalidData, | ||
| 157 | "Unrecognized cache or unsupported schema version", | ||
| 158 | ) | ||
| 159 | .into()); | ||
| 160 | } | ||
| 161 | validate_images(&transaction)?; | ||
| 162 | if version < SCHEMA_VERSION { | ||
| 163 | schema::migrate(&transaction, version)?; | ||
| 164 | transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?; | ||
| 165 | } | ||
| 166 | pending(&transaction)?; | ||
| 167 | } | ||
| 168 | transaction.commit()?; | ||
| 169 | Ok(Self { | ||
| 170 | connection: Mutex::new(connection), | ||
| 171 | synchronization: Mutex::new(()), | ||
| 172 | worker: Mutex::new(std::sync::Weak::new()), | ||
| 173 | }) | ||
| 174 | } | ||
| 175 | |||
| 176 | /// Returns the latest complete locally committed image, including pending edits. | ||
| 177 | pub fn snapshot(&self) -> Result<Vec<u8>> { | ||
| 178 | let connection = self | ||
| 179 | .connection | ||
| 180 | .lock() | ||
| 181 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 182 | Ok( | ||
| 183 | connection.query_row("SELECT working FROM replica WHERE id=1", [], |row| { | ||
| 184 | row.get(0) | ||
| 185 | })?, | ||
| 186 | ) | ||
| 187 | } | ||
| 188 | |||
| 189 | pub fn pending(&self) -> Result<Vec<PendingEdit>> { | ||
| 190 | let connection = self | ||
| 191 | .connection | ||
| 192 | .lock() | ||
| 193 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 194 | pending(&connection) | ||
| 195 | } | ||
| 196 | |||
| 197 | /// Atomically records an intent and its resulting local image; returns its durable ID. | ||
| 198 | /// Unchanged text returns `None`. A stale image returns `Io(ResourceBusy)`. | ||
| 199 | /// On synchronization, replacement text inherits the remote style at the rebased start. | ||
| 200 | /// After a database error, reopen and inspect the cache before retrying the edit. | ||
| 201 | pub fn edit_text( | ||
| 202 | &self, | ||
| 203 | source: &[u8], | ||
| 204 | space: ExGuid, | ||
| 205 | object: ExGuid, | ||
| 206 | range: Range<u32>, | ||
| 207 | replacement: &str, | ||
| 208 | ) -> Result<Option<u64>> { | ||
| 209 | let edit = PreparedEdit::text(source, space, object, range.clone(), replacement)?; | ||
| 210 | let before = paragraph(source, space, object)?.ok_or_else(|| { | ||
| 211 | io::Error::new( | ||
| 212 | io::ErrorKind::InvalidData, | ||
| 213 | "Prepared edit has no text target", | ||
| 214 | ) | ||
| 215 | })?; | ||
| 216 | self.record( | ||
| 217 | source, | ||
| 218 | space, | ||
| 219 | Operation::Text(TextEdit { | ||
| 220 | object, | ||
| 221 | before, | ||
| 222 | range, | ||
| 223 | replacement: replacement.to_owned(), | ||
| 224 | }), | ||
| 225 | &edit, | ||
| 226 | ) | ||
| 227 | } | ||
| 228 | |||
| 229 | /// Durably queues a validated insertion with its stable object identities. | ||
| 230 | /// Uses the same snapshot and local-acknowledgement contract as `edit_text`. | ||
| 231 | pub fn insert( | ||
| 232 | &self, | ||
| 233 | source: &[u8], | ||
| 234 | space: ExGuid, | ||
| 235 | insertion: &Insertion, | ||
| 236 | ) -> Result<Option<u64>> { | ||
| 237 | let edit = PreparedEdit::insert(source, space, insertion)?; | ||
| 238 | self.record(source, space, Operation::Insert(insertion.clone()), &edit) | ||
| 239 | } | ||
| 240 | |||
| 241 | /// Queues a new page and its section entry with stable identities for dependent edits. | ||
| 242 | pub fn create_page(&self, source: &[u8], page: &PageCreation) -> Result<Option<u64>> { | ||
| 243 | let edit = PreparedEdit::create_page(source, page)?; | ||
| 244 | self.record( | ||
| 245 | source, | ||
| 246 | page.space(), | ||
| 247 | Operation::CreatePage(page.clone()), | ||
| 248 | &edit, | ||
| 249 | ) | ||
| 250 | } | ||
| 251 | |||
| 252 | fn record( | ||
| 253 | &self, | ||
| 254 | source: &[u8], | ||
| 255 | space: ExGuid, | ||
| 256 | operation: Operation, | ||
| 257 | edit: &PreparedEdit<'_>, | ||
| 258 | ) -> Result<Option<u64>> { | ||
| 259 | let mut connection = self | ||
| 260 | .connection | ||
| 261 | .lock() | ||
| 262 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 263 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 264 | let current: Vec<u8> = | ||
| 265 | transaction.query_row("SELECT working FROM replica WHERE id=1", [], |row| { | ||
| 266 | row.get(0) | ||
| 267 | })?; | ||
| 268 | if current != source { | ||
| 269 | return Err(io::Error::new( | ||
| 270 | io::ErrorKind::ResourceBusy, | ||
| 271 | "The local snapshot changed before this edit", | ||
| 272 | ) | ||
| 273 | .into()); | ||
| 274 | } | ||
| 275 | if edit.as_bytes() == source { | ||
| 276 | return Ok(None); | ||
| 277 | } | ||
| 278 | transaction.execute( | ||
| 279 | "INSERT INTO edits(space, operation) VALUES (?1, ?2)", | ||
| 280 | params![ | ||
| 281 | space.to_string(), | ||
| 282 | serde_json::to_string(&operation).map_err(io::Error::other)? | ||
| 283 | ], | ||
| 284 | )?; | ||
| 285 | let id = u64::try_from(transaction.last_insert_rowid()).map_err(io::Error::other)?; | ||
| 286 | transaction.execute( | ||
| 287 | "UPDATE replica SET working=?1 WHERE id=1", | ||
| 288 | [edit.as_bytes()], | ||
| 289 | )?; | ||
| 290 | transaction.commit()?; | ||
| 291 | drop(connection); | ||
| 292 | self.wake_sync(); | ||
| 293 | Ok(Some(id)) | ||
| 294 | } | ||
| 295 | |||
| 296 | fn wake_sync(&self) { | ||
| 297 | if let Ok(worker) = self.worker.lock() | ||
| 298 | && let Some(worker) = worker.upgrade() | ||
| 299 | { | ||
| 300 | worker.wake(); | ||
| 301 | } | ||
| 302 | } | ||
| 303 | } | ||
| 304 | |||
| 305 | fn active_paths( | ||
| 306 | view: &onestore::document::Revision<'_>, | ||
| 307 | pages: &[ExGuid], | ||
| 308 | objects: &[ExGuid], | ||
| 309 | ) -> Option<Vec<Vec<ExGuid>>> { | ||
| 310 | let parents = view.parents(pages).ok()?; | ||
| 311 | objects | ||
| 312 | .iter() | ||
| 313 | .map(|object| { | ||
| 314 | if !parents.contains_key(object) && !pages.contains(object) { | ||
| 315 | return None; | ||
| 316 | } | ||
| 317 | let mut path = Vec::new(); | ||
| 318 | let mut at = *object; | ||
| 319 | while !pages.contains(&at) { | ||
| 320 | let [parent] = parents.get(&at)?.as_slice() else { | ||
| 321 | return None; | ||
| 322 | }; | ||
| 323 | if path.len() >= view.nodes.len() { | ||
| 324 | return None; | ||
| 325 | } | ||
| 326 | path.push(*parent); | ||
| 327 | at = *parent; | ||
| 328 | } | ||
| 329 | Some(path) | ||
| 330 | }) | ||
| 331 | .collect() | ||
| 332 | } | ||
| 333 | |||
| 334 | fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result<Option<String>> { | ||
| 335 | let store = Store::parse(source)?; | ||
| 336 | let index = RevisionIndex::parse(&store)?; | ||
| 337 | let document = Document::parse(&index)?; | ||
| 338 | let node = document | ||
| 339 | .active(space) | ||
| 340 | .ok() | ||
| 341 | .and_then(|revision| revision.nodes.get(&object)); | ||
| 342 | Ok(match node.map(|node| &node.kind) { | ||
| 343 | Some(Kind::RichText { text, .. }) => Some(text.clone()), | ||
| 344 | _ => None, | ||
| 345 | }) | ||
| 346 | } | ||
| 347 | |||
| 348 | fn validate(source: &[u8]) -> Result<ExGuid> { | ||
| 349 | let store = Store::parse(source)?; | ||
| 350 | if !store.checksum_mismatches.is_empty() { | ||
| 351 | return Err(io::Error::new( | ||
| 352 | io::ErrorKind::InvalidData, | ||
| 353 | "Notebook transaction checksum damage", | ||
| 354 | ) | ||
| 355 | .into()); | ||
| 356 | } | ||
| 357 | let index = RevisionIndex::parse(&store)?; | ||
| 358 | index.validate_current()?; | ||
| 359 | Document::parse(&index)?; | ||
| 360 | Ok(index.root) | ||
| 361 | } | ||
| 362 | |||
| 363 | fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> { | ||
| 364 | let mut query = connection.prepare("SELECT id, space, operation FROM edits ORDER BY id")?; | ||
| 365 | let mut rows = query.query([])?; | ||
| 366 | let mut edits = Vec::new(); | ||
| 367 | while let Some(row) = rows.next()? { | ||
| 368 | let edit = PendingEdit { | ||
| 369 | id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?, | ||
| 370 | space: row.get::<_, String>(1)?.parse()?, | ||
| 371 | operation: serde_json::from_str(&row.get::<_, String>(2)?) | ||
| 372 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?, | ||
| 373 | }; | ||
| 374 | if matches!(&edit.operation, Operation::CreatePage(page) if page.space() != edit.space) { | ||
| 375 | return Err(io::Error::new( | ||
| 376 | io::ErrorKind::InvalidData, | ||
| 377 | "Cached page identity differs from its creation intent", | ||
| 378 | ) | ||
| 379 | .into()); | ||
| 380 | } | ||
| 381 | edits.push(edit); | ||
| 382 | } | ||
| 383 | Ok(edits) | ||
| 384 | } | ||
| 385 | |||
| 386 | fn cache_connection(path: &Path) -> Result<Connection> { | ||
| 387 | let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; | ||
| 388 | connection.busy_timeout(Duration::ZERO)?; | ||
| 389 | connection.execute_batch( | ||
| 390 | "PRAGMA locking_mode=EXCLUSIVE; PRAGMA synchronous=EXTRA; PRAGMA fullfsync=ON; PRAGMA foreign_keys=ON;", | ||
| 391 | )?; | ||
| 392 | for (name, expected) in [("locking_mode", "exclusive"), ("journal_mode", "delete")] { | ||
| 393 | let actual: String = connection.pragma_query_value(None, name, |row| row.get(0))?; | ||
| 394 | if actual != expected { | ||
| 395 | return Err(io::Error::new( | ||
| 396 | io::ErrorKind::InvalidData, | ||
| 397 | "Unsupported cache locking or journal mode", | ||
| 398 | ) | ||
| 399 | .into()); | ||
| 400 | } | ||
| 401 | } | ||
| 402 | for (name, expected) in [("synchronous", 3), ("fullfsync", 1), ("foreign_keys", 1)] { | ||
| 403 | let actual: i64 = connection.pragma_query_value(None, name, |row| row.get(0))?; | ||
| 404 | if actual != expected { | ||
| 405 | return Err(io::Error::new( | ||
| 406 | io::ErrorKind::Unsupported, | ||
| 407 | "Required cache synchronization is unavailable", | ||
| 408 | ) | ||
| 409 | .into()); | ||
| 410 | } | ||
| 411 | } | ||
| 412 | Ok(connection) | ||
| 413 | } | ||
| 414 | |||
| 415 | fn validate_images(connection: &Connection) -> Result<()> { | ||
| 416 | let integrity: String = connection.query_row("PRAGMA quick_check", [], |row| row.get(0))?; | ||
| 417 | if integrity != "ok" { | ||
| 418 | return Err( | ||
| 419 | io::Error::new(io::ErrorKind::InvalidData, "Cache integrity check failed").into(), | ||
| 420 | ); | ||
| 421 | } | ||
| 422 | let (base, working): (Vec<u8>, Vec<u8>) = | ||
| 423 | connection.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| { | ||
| 424 | Ok((row.get(0)?, row.get(1)?)) | ||
| 425 | })?; | ||
| 426 | if validate(&base)? != validate(&working)? { | ||
| 427 | return Err(io::Error::new( | ||
| 428 | io::ErrorKind::InvalidData, | ||
| 429 | "Cache images belong to different documents", | ||
| 430 | ) | ||
| 431 | .into()); | ||
| 432 | } | ||
| 433 | Ok(()) | ||
| 434 | } | ||
crates/notebook/src/outline.rs created+138| ... | @@ -0,0 +1,138 @@ | ||
| 1 | use super::*; | ||
| 2 | use serde::{Deserialize, Serialize}; | ||
| 3 | use serde_json::{Value, json}; | ||
| 4 | |||
| 5 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 6 | #[serde(deny_unknown_fields)] | ||
| 7 | struct Observed { | ||
| 8 | path: Vec<ExGuid>, | ||
| 9 | values: Vec<Value>, | ||
| 10 | } | ||
| 11 | |||
| 12 | /// A layout or saved expansion intent with its original ancestry and property values. | ||
| 13 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 14 | #[serde(deny_unknown_fields)] | ||
| 15 | pub struct OutlineEdit { | ||
| 16 | pub object: ExGuid, | ||
| 17 | pub change: onestore::OutlineEdit, | ||
| 18 | observed: Observed, | ||
| 19 | } | ||
| 20 | |||
| 21 | fn observe( | ||
| 22 | source: &[u8], | ||
| 23 | space: ExGuid, | ||
| 24 | object: ExGuid, | ||
| 25 | change: onestore::OutlineEdit, | ||
| 26 | ) -> Result<Option<Observed>> { | ||
| 27 | let store = Store::parse(source)?; | ||
| 28 | let index = RevisionIndex::parse(&store)?; | ||
| 29 | let document = Document::parse(&index)?; | ||
| 30 | let Ok(view) = document.active(space) else { | ||
| 31 | return Ok(None); | ||
| 32 | }; | ||
| 33 | let pages = document.pages_in(space)?; | ||
| 34 | if pages.len() != 1 { | ||
| 35 | return Ok(None); | ||
| 36 | } | ||
| 37 | let Some(mut paths) = active_paths(view, &pages, &[object]) else { | ||
| 38 | return Ok(None); | ||
| 39 | }; | ||
| 40 | let path = paths.pop().unwrap(); | ||
| 41 | let node = &view.nodes[&object]; | ||
| 42 | let values = match (change, &node.kind) { | ||
| 43 | (onestore::OutlineEdit::Position { .. }, Kind::Outline { .. }) => { | ||
| 44 | vec![json!(node.layout.x), json!(node.layout.y)] | ||
| 45 | } | ||
| 46 | (onestore::OutlineEdit::Width { .. }, Kind::Outline { .. }) => { | ||
| 47 | vec![ | ||
| 48 | json!(node.layout.max_width), | ||
| 49 | json!(node.layout.width_set_by_user.unwrap_or(false)), | ||
| 50 | json!(node.layout.reserved_width), | ||
| 51 | ] | ||
| 52 | } | ||
| 53 | (onestore::OutlineEdit::Collapsed(_), Kind::Paragraph { collapse_state, .. }) => { | ||
| 54 | vec![json!(collapse_state.unwrap_or(0))] | ||
| 55 | } | ||
| 56 | _ => return Ok(None), | ||
| 57 | }; | ||
| 58 | Ok(Some(Observed { path, values })) | ||
| 59 | } | ||
| 60 | |||
| 61 | impl Replica { | ||
| 62 | /// Durably records layout or saved expansion changes, preserving independent remote edits. | ||
| 63 | pub fn outline( | ||
| 64 | &self, | ||
| 65 | source: &[u8], | ||
| 66 | space: ExGuid, | ||
| 67 | object: ExGuid, | ||
| 68 | change: onestore::OutlineEdit, | ||
| 69 | ) -> Result<Option<u64>> { | ||
| 70 | let (edit, prepared) = OutlineEdit::capture(source, space, object, change)?; | ||
| 71 | self.record(source, space, Operation::Outline(edit), &prepared) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | impl OutlineEdit { | ||
| 76 | pub(crate) fn capture<'a>( | ||
| 77 | source: &'a [u8], | ||
| 78 | space: ExGuid, | ||
| 79 | object: ExGuid, | ||
| 80 | change: onestore::OutlineEdit, | ||
| 81 | ) -> Result<(Self, PreparedEdit<'a>)> { | ||
| 82 | let prepared = PreparedEdit::outline(source, space, object, change)?; | ||
| 83 | let observed = observe(source, space, object, change)?.ok_or_else(|| { | ||
| 84 | io::Error::new( | ||
| 85 | io::ErrorKind::InvalidData, | ||
| 86 | "Prepared outline edit has no active target", | ||
| 87 | ) | ||
| 88 | })?; | ||
| 89 | Ok(( | ||
| 90 | Self { | ||
| 91 | object, | ||
| 92 | change, | ||
| 93 | observed, | ||
| 94 | }, | ||
| 95 | prepared, | ||
| 96 | )) | ||
| 97 | } | ||
| 98 | |||
| 99 | pub(crate) fn prepare<'a>( | ||
| 100 | &self, | ||
| 101 | snapshot: &'a [u8], | ||
| 102 | space: ExGuid, | ||
| 103 | ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> { | ||
| 104 | let Some(current) = observe(snapshot, space, self.object, self.change)? else { | ||
| 105 | return Ok(Err(ConflictKind::TargetUnavailable)); | ||
| 106 | }; | ||
| 107 | if current.path != self.observed.path { | ||
| 108 | return Ok(Err(ConflictKind::StructureChanged)); | ||
| 109 | } | ||
| 110 | let prepared = match PreparedEdit::outline(snapshot, space, self.object, self.change) { | ||
| 111 | Ok(prepared) => prepared, | ||
| 112 | Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)), | ||
| 113 | }; | ||
| 114 | let wanted = | ||
| 115 | observe(prepared.as_bytes(), space, self.object, self.change)?.ok_or_else(|| { | ||
| 116 | io::Error::new( | ||
| 117 | io::ErrorKind::InvalidData, | ||
| 118 | "Prepared outline edit lost its target", | ||
| 119 | ) | ||
| 120 | })?; | ||
| 121 | if self.observed.values.len() != current.values.len() | ||
| 122 | || wanted.values.len() != current.values.len() | ||
| 123 | { | ||
| 124 | return Ok(Err(ConflictKind::UnsupportedEdit)); | ||
| 125 | } | ||
| 126 | if self | ||
| 127 | .observed | ||
| 128 | .values | ||
| 129 | .iter() | ||
| 130 | .zip(&current.values) | ||
| 131 | .zip(&wanted.values) | ||
| 132 | .any(|((before, current), wanted)| current != before && current != wanted) | ||
| 133 | { | ||
| 134 | return Ok(Err(ConflictKind::LayoutChanged)); | ||
| 135 | } | ||
| 136 | Ok(Ok(prepared)) | ||
| 137 | } | ||
| 138 | } | ||
crates/notebook/src/pages.rs created+168| ... | @@ -0,0 +1,168 @@ | ||
| 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/paragraph.rs created+200| ... | @@ -0,0 +1,200 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{ParagraphJoin, ParagraphSplit}; | ||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | use serde_json::{Value, json}; | ||
| 5 | |||
| 6 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 7 | #[serde(deny_unknown_fields)] | ||
| 8 | struct Observed { | ||
| 9 | text: String, | ||
| 10 | structure: Value, | ||
| 11 | } | ||
| 12 | |||
| 13 | /// A stable split intent and the paragraph state observed before local publication. | ||
| 14 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 15 | #[serde(deny_unknown_fields)] | ||
| 16 | pub struct SplitEdit { | ||
| 17 | pub intent: ParagraphSplit, | ||
| 18 | observed: Observed, | ||
| 19 | } | ||
| 20 | |||
| 21 | /// A join intent and both paragraphs' observed text, placement and tag ownership. | ||
| 22 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 23 | #[serde(deny_unknown_fields)] | ||
| 24 | pub struct JoinEdit { | ||
| 25 | pub intent: ParagraphJoin, | ||
| 26 | observed: [Observed; 2], | ||
| 27 | } | ||
| 28 | |||
| 29 | fn observe(source: &[u8], space: ExGuid, texts: &[ExGuid]) -> Result<Option<Vec<Observed>>> { | ||
| 30 | let store = Store::parse(source)?; | ||
| 31 | let index = RevisionIndex::parse(&store)?; | ||
| 32 | let document = Document::parse(&index)?; | ||
| 33 | let Ok(view) = document.active(space) else { | ||
| 34 | return Ok(None); | ||
| 35 | }; | ||
| 36 | let pages = document.pages_in(space)?; | ||
| 37 | let Some(paths) = active_paths(view, &pages, texts) else { | ||
| 38 | return Ok(None); | ||
| 39 | }; | ||
| 40 | let mut observed = Vec::new(); | ||
| 41 | for (text, path) in texts.iter().zip(paths) { | ||
| 42 | let node = &view.nodes[text]; | ||
| 43 | let Kind::RichText { text: content, .. } = &node.kind else { | ||
| 44 | return Ok(None); | ||
| 45 | }; | ||
| 46 | let Some(paragraph) = path.first().and_then(|id| view.nodes.get(id)) else { | ||
| 47 | return Ok(None); | ||
| 48 | }; | ||
| 49 | let Kind::Paragraph { lists, .. } = &paragraph.kind else { | ||
| 50 | return Ok(None); | ||
| 51 | }; | ||
| 52 | if paragraph.content != [*text] { | ||
| 53 | return Ok(None); | ||
| 54 | } | ||
| 55 | let lists: Vec<_> = lists.iter().map(|id| (*id, &view.nodes[id].kind)).collect(); | ||
| 56 | observed.push(Observed { | ||
| 57 | text: content.clone(), | ||
| 58 | structure: json!({ | ||
| 59 | "path": path, | ||
| 60 | "children": paragraph.children, | ||
| 61 | "child_level": paragraph.child_level, | ||
| 62 | "paragraph": paragraph.kind, | ||
| 63 | "lists": lists, | ||
| 64 | "tags": node.tags, | ||
| 65 | }), | ||
| 66 | }); | ||
| 67 | } | ||
| 68 | Ok(Some(observed)) | ||
| 69 | } | ||
| 70 | |||
| 71 | impl Replica { | ||
| 72 | /// Durably queues a split with stable new identities and observed structural preconditions. | ||
| 73 | pub fn split( | ||
| 74 | &self, | ||
| 75 | source: &[u8], | ||
| 76 | space: ExGuid, | ||
| 77 | intent: &ParagraphSplit, | ||
| 78 | ) -> Result<Option<u64>> { | ||
| 79 | let (operation, prepared) = SplitEdit::capture(source, space, intent)?; | ||
| 80 | self.record(source, space, Operation::Split(operation), &prepared) | ||
| 81 | } | ||
| 82 | |||
| 83 | /// Durably queues a join; changed placement, children or tags require conflict review. | ||
| 84 | pub fn join( | ||
| 85 | &self, | ||
| 86 | source: &[u8], | ||
| 87 | space: ExGuid, | ||
| 88 | intent: &ParagraphJoin, | ||
| 89 | ) -> Result<Option<u64>> { | ||
| 90 | let (operation, prepared) = JoinEdit::capture(source, space, intent)?; | ||
| 91 | self.record(source, space, Operation::Join(operation), &prepared) | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | impl SplitEdit { | ||
| 96 | pub(crate) fn capture<'a>( | ||
| 97 | source: &'a [u8], | ||
| 98 | space: ExGuid, | ||
| 99 | intent: &ParagraphSplit, | ||
| 100 | ) -> Result<(Self, PreparedEdit<'a>)> { | ||
| 101 | let prepared = PreparedEdit::split(source, space, intent)?; | ||
| 102 | let Some(mut observed) = observe(source, space, &[intent.position().0])? else { | ||
| 103 | return Err(io::Error::new( | ||
| 104 | io::ErrorKind::InvalidData, | ||
| 105 | "Select text in an active paragraph", | ||
| 106 | ) | ||
| 107 | .into()); | ||
| 108 | }; | ||
| 109 | Ok(( | ||
| 110 | Self { | ||
| 111 | intent: intent.clone(), | ||
| 112 | observed: observed.remove(0), | ||
| 113 | }, | ||
| 114 | prepared, | ||
| 115 | )) | ||
| 116 | } | ||
| 117 | |||
| 118 | pub(crate) fn prepare<'a>( | ||
| 119 | &self, | ||
| 120 | source: &'a [u8], | ||
| 121 | space: ExGuid, | ||
| 122 | ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> { | ||
| 123 | let (text, offset) = self.intent.position(); | ||
| 124 | let Some(observed) = observe(source, space, &[text])? else { | ||
| 125 | return Ok(Err(ConflictKind::TargetUnavailable)); | ||
| 126 | }; | ||
| 127 | if observed[0].structure != self.observed.structure { | ||
| 128 | return Ok(Err(ConflictKind::StructureChanged)); | ||
| 129 | } | ||
| 130 | let Some(range) = rebase::rebase(&self.observed.text, &observed[0].text, offset..offset) | ||
| 131 | else { | ||
| 132 | return Ok(Err(ConflictKind::TextChanged)); | ||
| 133 | }; | ||
| 134 | Ok( | ||
| 135 | PreparedEdit::split(source, space, &self.intent.reposition(range.start)) | ||
| 136 | .map_err(|_| ConflictKind::UnsupportedEdit), | ||
| 137 | ) | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | impl JoinEdit { | ||
| 142 | pub(crate) fn review(&self, source: &[u8], space: ExGuid) -> Result<Self> { | ||
| 143 | let (reviewed, _) = Self::capture(source, space, &self.intent)?; | ||
| 144 | if reviewed.observed[0].text.is_empty() != self.observed[0].text.is_empty() { | ||
| 145 | return Err(io::Error::new( | ||
| 146 | io::ErrorKind::InvalidInput, | ||
| 147 | "The join would change its surviving text identity", | ||
| 148 | ) | ||
| 149 | .into()); | ||
| 150 | } | ||
| 151 | Ok(reviewed) | ||
| 152 | } | ||
| 153 | |||
| 154 | pub(crate) fn capture<'a>( | ||
| 155 | source: &'a [u8], | ||
| 156 | space: ExGuid, | ||
| 157 | intent: &ParagraphJoin, | ||
| 158 | ) -> Result<(Self, PreparedEdit<'a>)> { | ||
| 159 | let prepared = PreparedEdit::join(source, space, intent)?; | ||
| 160 | let Some(observed) = observe(source, space, &intent.texts())? else { | ||
| 161 | return Err(io::Error::new( | ||
| 162 | io::ErrorKind::InvalidData, | ||
| 163 | "Select text in two active paragraphs", | ||
| 164 | ) | ||
| 165 | .into()); | ||
| 166 | }; | ||
| 167 | Ok(( | ||
| 168 | Self { | ||
| 169 | intent: intent.clone(), | ||
| 170 | observed: observed.try_into().unwrap(), | ||
| 171 | }, | ||
| 172 | prepared, | ||
| 173 | )) | ||
| 174 | } | ||
| 175 | |||
| 176 | pub(crate) fn prepare<'a>( | ||
| 177 | &self, | ||
| 178 | source: &'a [u8], | ||
| 179 | space: ExGuid, | ||
| 180 | ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> { | ||
| 181 | let Some(observed) = observe(source, space, &self.intent.texts())? else { | ||
| 182 | return Ok(Err(ConflictKind::TargetUnavailable)); | ||
| 183 | }; | ||
| 184 | for (i, (old, new)) in self.observed.iter().zip(&observed).enumerate() { | ||
| 185 | if old.structure != new.structure { | ||
| 186 | return Ok(Err(ConflictKind::StructureChanged)); | ||
| 187 | } | ||
| 188 | let at = if i == 0 { | ||
| 189 | u32::try_from(old.text.encode_utf16().count()).map_err(io::Error::other)? | ||
| 190 | } else { | ||
| 191 | 0 | ||
| 192 | }; | ||
| 193 | if rebase::rebase(&old.text, &new.text, at..at).is_none() { | ||
| 194 | return Ok(Err(ConflictKind::TextChanged)); | ||
| 195 | } | ||
| 196 | } | ||
| 197 | Ok(PreparedEdit::join(source, space, &self.intent) | ||
| 198 | .map_err(|_| ConflictKind::UnsupportedEdit)) | ||
| 199 | } | ||
| 200 | } | ||
crates/notebook/src/rebase.rs created+297| ... | @@ -0,0 +1,297 @@ | ||
| 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 created+190| ... | @@ -0,0 +1,190 @@ | ||
| 1 | use super::*; | ||
| 2 | use rusqlite::backup::{Backup, StepResult}; | ||
| 3 | use std::{collections::BTreeMap, fs::File}; | ||
| 4 | |||
| 5 | const RECOVERY_ID: u32 = 0x4f4e4552; | ||
| 6 | |||
| 7 | /// Counts and image sizes without notebook text, paths, authors or credentials. | ||
| 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] | ||
| 9 | pub struct RecoverySummary { | ||
| 10 | pub queued_edits: u64, | ||
| 11 | pub conflicts: u64, | ||
| 12 | pub uncertain_edits: u64, | ||
| 13 | pub published_receipts: u64, | ||
| 14 | pub working_bytes: u64, | ||
| 15 | pub remote_bytes: u64, | ||
| 16 | pub cached_assets: u64, | ||
| 17 | pub cached_asset_bytes: u64, | ||
| 18 | } | ||
| 19 | |||
| 20 | /// Read-only recovery evidence; it cannot publish or acknowledge an edit. | ||
| 21 | pub struct Recovery { | ||
| 22 | connection: Connection, | ||
| 23 | } | ||
| 24 | |||
| 25 | impl Recovery { | ||
| 26 | /// Opens an exported archive without migration or conversion into a writable replica. | ||
| 27 | pub fn open(path: impl AsRef<Path>) -> Result<Self> { | ||
| 28 | let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; | ||
| 29 | connection.busy_timeout(Duration::ZERO)?; | ||
| 30 | connection.execute_batch("BEGIN")?; | ||
| 31 | let application: u32 = | ||
| 32 | connection.pragma_query_value(None, "application_id", |row| row.get(0))?; | ||
| 33 | let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; | ||
| 34 | if application != RECOVERY_ID || !(4..=SCHEMA_VERSION).contains(&version) { | ||
| 35 | return Err(io::Error::new( | ||
| 36 | io::ErrorKind::InvalidData, | ||
| 37 | "Unrecognized recovery archive or unsupported schema version", | ||
| 38 | ) | ||
| 39 | .into()); | ||
| 40 | } | ||
| 41 | validate_images(&connection)?; | ||
| 42 | for edit in pending(&connection)? { | ||
| 43 | sync::status(&connection, edit.id)?; | ||
| 44 | } | ||
| 45 | receipts(&connection)?; | ||
| 46 | Ok(Self { connection }) | ||
| 47 | } | ||
| 48 | |||
| 49 | pub fn summary(&self) -> Result<RecoverySummary> { | ||
| 50 | summary(&self.connection) | ||
| 51 | } | ||
| 52 | |||
| 53 | pub fn snapshot(&self) -> Result<Vec<u8>> { | ||
| 54 | Ok(self | ||
| 55 | .connection | ||
| 56 | .query_row("SELECT working FROM replica WHERE id=1", [], |row| { | ||
| 57 | row.get(0) | ||
| 58 | })?) | ||
| 59 | } | ||
| 60 | |||
| 61 | pub fn remote_snapshot(&self) -> Result<Vec<u8>> { | ||
| 62 | Ok(self | ||
| 63 | .connection | ||
| 64 | .query_row("SELECT base FROM replica WHERE id=1", [], |row| row.get(0))?) | ||
| 65 | } | ||
| 66 | |||
| 67 | pub fn pending(&self) -> Result<Vec<PendingEdit>> { | ||
| 68 | pending(&self.connection) | ||
| 69 | } | ||
| 70 | |||
| 71 | pub fn status(&self, id: u64) -> Result<Option<EditStatus>> { | ||
| 72 | sync::status(&self.connection, id) | ||
| 73 | } | ||
| 74 | |||
| 75 | pub fn receipts(&self) -> Result<BTreeMap<u64, ExGuid>> { | ||
| 76 | receipts(&self.connection) | ||
| 77 | } | ||
| 78 | |||
| 79 | /// Reads a previously downloaded external payload without accessing its former server. | ||
| 80 | pub fn cached_asset(&self, filename: &str, limit: usize) -> Result<Option<Vec<u8>>> { | ||
| 81 | assets::cached(&self.connection, &assets::key(filename)?, limit) | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | impl Replica { | ||
| 86 | /// Summarizes durable state without exposing notebook content. | ||
| 87 | pub fn recovery_summary(&self) -> Result<RecoverySummary> { | ||
| 88 | let connection = self | ||
| 89 | .connection | ||
| 90 | .lock() | ||
| 91 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 92 | summary(&connection) | ||
| 93 | } | ||
| 94 | |||
| 95 | /// Exports a consistent archive to a new local path without changing the live queue. | ||
| 96 | /// Archives contain notebook content and cannot be opened as writable replicas. | ||
| 97 | /// An error after publication can leave a complete archive at the destination. | ||
| 98 | pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> { | ||
| 99 | let path = path.as_ref(); | ||
| 100 | if path.file_name().is_none() { | ||
| 101 | return Err(io::Error::new( | ||
| 102 | io::ErrorKind::InvalidInput, | ||
| 103 | "Provide a new recovery archive filename", | ||
| 104 | ) | ||
| 105 | .into()); | ||
| 106 | } | ||
| 107 | let parent = path | ||
| 108 | .parent() | ||
| 109 | .filter(|parent| !parent.as_os_str().is_empty()) | ||
| 110 | .unwrap_or_else(|| Path::new(".")); | ||
| 111 | let temporary = tempfile::Builder::new() | ||
| 112 | .prefix(".onestore-recovery-") | ||
| 113 | .tempfile_in(parent)?; | ||
| 114 | let mut destination = cache_connection(temporary.path())?; | ||
| 115 | { | ||
| 116 | let source = self | ||
| 117 | .connection | ||
| 118 | .lock() | ||
| 119 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 120 | let backup = Backup::new(&source, &mut destination)?; | ||
| 121 | if backup.step(-1)? != StepResult::Done { | ||
| 122 | return Err(io::Error::new( | ||
| 123 | io::ErrorKind::WouldBlock, | ||
| 124 | "Recovery export could not acquire the database snapshot", | ||
| 125 | ) | ||
| 126 | .into()); | ||
| 127 | } | ||
| 128 | } | ||
| 129 | destination.pragma_update(None, "application_id", RECOVERY_ID)?; | ||
| 130 | destination.close().map_err(|(_, error)| error)?; | ||
| 131 | temporary.as_file().sync_all()?; | ||
| 132 | temporary | ||
| 133 | .persist_noclobber(path) | ||
| 134 | .map_err(|error| error.error)?; | ||
| 135 | File::open(parent)?.sync_all()?; | ||
| 136 | Ok(()) | ||
| 137 | } | ||
| 138 | } | ||
| 139 | |||
| 140 | fn summary(connection: &Connection) -> Result<RecoverySummary> { | ||
| 141 | let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; | ||
| 142 | let (cached_assets, cached_asset_bytes) = if version < 5 { | ||
| 143 | (0, 0) | ||
| 144 | } else { | ||
| 145 | connection.query_row( | ||
| 146 | "SELECT count(*),coalesce(sum(length(data)),0) FROM assets", | ||
| 147 | [], | ||
| 148 | |row| Ok((unsigned(row, 0)?, unsigned(row, 1)?)), | ||
| 149 | )? | ||
| 150 | }; | ||
| 151 | Ok(connection.query_row( | ||
| 152 | "SELECT (SELECT count(*) FROM edits), (SELECT count(*) FROM conflicts), | ||
| 153 | (SELECT count(*) FROM attempt), (SELECT count(*) FROM receipts), | ||
| 154 | length(working), length(base) FROM replica WHERE id=1", | ||
| 155 | [], | ||
| 156 | |row| { | ||
| 157 | Ok(RecoverySummary { | ||
| 158 | queued_edits: unsigned(row, 0)?, | ||
| 159 | conflicts: unsigned(row, 1)?, | ||
| 160 | uncertain_edits: unsigned(row, 2)?, | ||
| 161 | published_receipts: unsigned(row, 3)?, | ||
| 162 | working_bytes: unsigned(row, 4)?, | ||
| 163 | remote_bytes: unsigned(row, 5)?, | ||
| 164 | cached_assets, | ||
| 165 | cached_asset_bytes, | ||
| 166 | }) | ||
| 167 | }, | ||
| 168 | )?) | ||
| 169 | } | ||
| 170 | |||
| 171 | fn receipts(connection: &Connection) -> Result<BTreeMap<u64, ExGuid>> { | ||
| 172 | let mut statement = | ||
| 173 | connection.prepare("SELECT edit_id, revision FROM receipts ORDER BY edit_id")?; | ||
| 174 | let mut rows = statement.query([])?; | ||
| 175 | let mut receipts = BTreeMap::new(); | ||
| 176 | while let Some(row) = rows.next()? { | ||
| 177 | receipts.insert(unsigned(row, 0)?, row.get::<_, String>(1)?.parse()?); | ||
| 178 | } | ||
| 179 | Ok(receipts) | ||
| 180 | } | ||
| 181 | |||
| 182 | fn unsigned(row: &rusqlite::Row<'_>, column: usize) -> rusqlite::Result<u64> { | ||
| 183 | u64::try_from(row.get::<_, i64>(column)?).map_err(|error| { | ||
| 184 | rusqlite::Error::FromSqlConversionFailure( | ||
| 185 | column, | ||
| 186 | rusqlite::types::Type::Integer, | ||
| 187 | Box::new(error), | ||
| 188 | ) | ||
| 189 | }) | ||
| 190 | } | ||
crates/notebook/src/schema.rs created+143| ... | @@ -0,0 +1,143 @@ | ||
| 1 | use super::*; | ||
| 2 | use rusqlite::{OptionalExtension, Transaction}; | ||
| 3 | |||
| 4 | const CONFLICTS: &str = "CREATE TABLE conflicts ( | ||
| 5 | edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, | ||
| 6 | kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 6) | ||
| 7 | ) STRICT;"; | ||
| 8 | |||
| 9 | const ASSETS: &str = "CREATE TABLE assets ( | ||
| 10 | name TEXT PRIMARY KEY NOT NULL, | ||
| 11 | data BLOB NOT NULL, | ||
| 12 | sha256 BLOB NOT NULL CHECK(length(sha256)=32) | ||
| 13 | ) STRICT;"; | ||
| 14 | |||
| 15 | pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> { | ||
| 16 | transaction.execute_batch( | ||
| 17 | "CREATE TABLE edits ( | ||
| 18 | id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), | ||
| 19 | space TEXT NOT NULL, | ||
| 20 | operation TEXT NOT NULL | ||
| 21 | ) STRICT; | ||
| 22 | CREATE TABLE attempt ( | ||
| 23 | id INTEGER PRIMARY KEY CHECK(id=1), | ||
| 24 | edit_id INTEGER NOT NULL UNIQUE REFERENCES edits(id) ON DELETE CASCADE, | ||
| 25 | revisions TEXT NOT NULL | ||
| 26 | ) STRICT; | ||
| 27 | CREATE TABLE receipts ( | ||
| 28 | edit_id INTEGER PRIMARY KEY CHECK(edit_id>0), | ||
| 29 | revision TEXT NOT NULL | ||
| 30 | ) STRICT;", | ||
| 31 | )?; | ||
| 32 | transaction.execute_batch(CONFLICTS)?; | ||
| 33 | transaction.execute_batch(ASSETS)?; | ||
| 34 | Ok(()) | ||
| 35 | } | ||
| 36 | |||
| 37 | pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()> { | ||
| 38 | if version >= 10 { | ||
| 39 | return Ok(()); | ||
| 40 | } | ||
| 41 | if version >= 3 { | ||
| 42 | transaction.execute_batch("ALTER TABLE conflicts RENAME TO old_conflicts;")?; | ||
| 43 | transaction.execute_batch(CONFLICTS)?; | ||
| 44 | transaction.execute_batch( | ||
| 45 | "INSERT INTO conflicts SELECT * FROM old_conflicts; DROP TABLE old_conflicts;", | ||
| 46 | )?; | ||
| 47 | if version < 5 { | ||
| 48 | transaction.execute_batch(ASSETS)?; | ||
| 49 | } | ||
| 50 | transaction.execute_batch("ALTER TABLE attempt RENAME COLUMN revision TO revisions;")?; | ||
| 51 | migrate_attempts(transaction)?; | ||
| 52 | return Ok(()); | ||
| 53 | } | ||
| 54 | |||
| 55 | let mut query = transaction.prepare( | ||
| 56 | "SELECT id, space, object, before_text, start, end, replacement FROM edits ORDER BY id", | ||
| 57 | )?; | ||
| 58 | let mut rows = query.query([])?; | ||
| 59 | let mut edits = Vec::new(); | ||
| 60 | while let Some(row) = rows.next()? { | ||
| 61 | edits.push(PendingEdit { | ||
| 62 | id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?, | ||
| 63 | space: row.get::<_, String>(1)?.parse()?, | ||
| 64 | operation: Operation::Text(TextEdit { | ||
| 65 | object: row.get::<_, String>(2)?.parse()?, | ||
| 66 | before: row.get(3)?, | ||
| 67 | range: row.get(4)?..row.get(5)?, | ||
| 68 | replacement: row.get(6)?, | ||
| 69 | }), | ||
| 70 | }); | ||
| 71 | } | ||
| 72 | drop(rows); | ||
| 73 | drop(query); | ||
| 74 | let sequence: i64 = transaction | ||
| 75 | .query_row( | ||
| 76 | "SELECT seq FROM sqlite_sequence WHERE name='edits'", | ||
| 77 | [], | ||
| 78 | |row| row.get(0), | ||
| 79 | ) | ||
| 80 | .optional()? | ||
| 81 | .unwrap_or(0); | ||
| 82 | let (mut attempts, mut conflicts, mut receipts) = (Vec::new(), Vec::new(), Vec::new()); | ||
| 83 | if version == 2 { | ||
| 84 | let mut query = transaction.prepare("SELECT edit_id, revision FROM attempt")?; | ||
| 85 | attempts = query | ||
| 86 | .query_map([], |row| { | ||
| 87 | Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) | ||
| 88 | })? | ||
| 89 | .collect::<std::result::Result<_, _>>()?; | ||
| 90 | let mut query = transaction.prepare("SELECT edit_id, kind FROM conflicts")?; | ||
| 91 | conflicts = query | ||
| 92 | .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))? | ||
| 93 | .collect::<std::result::Result<_, _>>()?; | ||
| 94 | let mut query = transaction.prepare("SELECT edit_id, revision FROM receipts")?; | ||
| 95 | receipts = query | ||
| 96 | .query_map([], |row| { | ||
| 97 | Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) | ||
| 98 | })? | ||
| 99 | .collect::<std::result::Result<_, _>>()?; | ||
| 100 | transaction | ||
| 101 | .execute_batch("DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts;")?; | ||
| 102 | } | ||
| 103 | transaction.execute_batch("DROP TABLE edits;")?; | ||
| 104 | create(transaction)?; | ||
| 105 | for edit in edits { | ||
| 106 | transaction.execute( | ||
| 107 | "INSERT INTO edits(id,space,operation) VALUES (?1,?2,?3)", | ||
| 108 | params![ | ||
| 109 | i64::try_from(edit.id).map_err(io::Error::other)?, | ||
| 110 | edit.space.to_string(), | ||
| 111 | serde_json::to_string(&edit.operation).map_err(io::Error::other)? | ||
| 112 | ], | ||
| 113 | )?; | ||
| 114 | } | ||
| 115 | // A drained queue must not reuse IDs belonging to existing durable receipts. | ||
| 116 | transaction.execute("DELETE FROM sqlite_sequence WHERE name='edits'", [])?; | ||
| 117 | transaction.execute( | ||
| 118 | "INSERT INTO sqlite_sequence(name,seq) VALUES ('edits',?1)", | ||
| 119 | [sequence], | ||
| 120 | )?; | ||
| 121 | for (id, revision) in attempts { | ||
| 122 | transaction.execute( | ||
| 123 | "INSERT INTO attempt VALUES (1,?1,?2)", | ||
| 124 | params![id, revision], | ||
| 125 | )?; | ||
| 126 | } | ||
| 127 | for (id, kind) in conflicts { | ||
| 128 | transaction.execute("INSERT INTO conflicts VALUES (?1,?2)", params![id, kind])?; | ||
| 129 | } | ||
| 130 | for (id, revision) in receipts { | ||
| 131 | transaction.execute("INSERT INTO receipts VALUES (?1,?2)", params![id, revision])?; | ||
| 132 | } | ||
| 133 | migrate_attempts(transaction)?; | ||
| 134 | Ok(()) | ||
| 135 | } | ||
| 136 | |||
| 137 | fn migrate_attempts(transaction: &Transaction<'_>) -> Result<()> { | ||
| 138 | transaction.execute_batch( | ||
| 139 | "UPDATE attempt SET revisions=json_object( | ||
| 140 | (SELECT space FROM edits WHERE edits.id=attempt.edit_id), revisions);", | ||
| 141 | )?; | ||
| 142 | Ok(()) | ||
| 143 | } | ||
crates/notebook/src/smb/directory.rs created+219| ... | @@ -0,0 +1,219 @@ | ||
| 1 | use super::*; | ||
| 2 | use smb2::msg::query_directory::{ | ||
| 3 | FileInformationClass, QueryDirectoryFlags, QueryDirectoryRequest, QueryDirectoryResponse, | ||
| 4 | }; | ||
| 5 | use std::collections::BTreeMap; | ||
| 6 | |||
| 7 | /// Observed directory metadata, not a stable notebook identity or a file snapshot. | ||
| 8 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 9 | pub struct DirectoryEntry { | ||
| 10 | pub name: String, | ||
| 11 | pub size: u64, | ||
| 12 | /// MS-FSCC file attributes; directory is 0x10 and reparse point is 0x400. | ||
| 13 | pub attributes: u32, | ||
| 14 | } | ||
| 15 | |||
| 16 | impl Client { | ||
| 17 | /// Enumerates a share-relative directory completely or returns an error without a partial list. | ||
| 18 | /// An empty path selects the share root. The limit excludes `.` and `..`. | ||
| 19 | /// Concurrent directory changes are not an atomic snapshot; repeated names return ResourceBusy. | ||
| 20 | pub fn read_dir(&self, path: &str, limit: usize) -> io::Result<Vec<DirectoryEntry>> { | ||
| 21 | if path.contains('\0') || path.encode_utf16().count() > 32767 { | ||
| 22 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 23 | } | ||
| 24 | let response: CreateResponse = self.request( | ||
| 25 | Command::Create, | ||
| 26 | CreateRequest { | ||
| 27 | requested_oplock_level: OplockLevel::None, | ||
| 28 | impersonation_level: ImpersonationLevel::Impersonation, | ||
| 29 | desired_access: FileAccessMask::new(0x80000000), | ||
| 30 | file_attributes: 0, | ||
| 31 | share_access: ShareAccess(7), | ||
| 32 | create_disposition: CreateDisposition::FileOpen, | ||
| 33 | create_options: 0x1, | ||
| 34 | name: smb2::encode_path(&path.replace('\\', "/")), | ||
| 35 | create_contexts: Vec::new(), | ||
| 36 | }, | ||
| 37 | )?; | ||
| 38 | let file = File { | ||
| 39 | client: self, | ||
| 40 | id: Some(response.file_id), | ||
| 41 | }; | ||
| 42 | let mut entries = BTreeMap::new(); | ||
| 43 | loop { | ||
| 44 | let response: io::Result<QueryDirectoryResponse> = | ||
| 45 | self.request_with(Command::QueryDirectory, |connection| { | ||
| 46 | ( | ||
| 47 | QueryDirectoryRequest { | ||
| 48 | file_information_class: FileInformationClass::FileDirectoryInformation, | ||
| 49 | flags: QueryDirectoryFlags(if entries.is_empty() { 1 } else { 0 }), | ||
| 50 | file_index: 0, | ||
| 51 | file_id: file.id.unwrap(), | ||
| 52 | output_buffer_length: connection | ||
| 53 | .params() | ||
| 54 | .expect("connected SMB session is negotiated") | ||
| 55 | .max_transact_size | ||
| 56 | .min(65536), | ||
| 57 | file_name: "*".into(), | ||
| 58 | }, | ||
| 59 | CreditCharge(1), | ||
| 60 | ) | ||
| 61 | }); | ||
| 62 | let response = match response { | ||
| 63 | Ok(response) => response, | ||
| 64 | Err(error) | ||
| 65 | if matches!( | ||
| 66 | error.get_ref().and_then(|error| error.downcast_ref::<smb2::Error>()), | ||
| 67 | Some(smb2::Error::Protocol { status, command: Command::QueryDirectory }) | ||
| 68 | if status.0 == 0x80000006 || (entries.is_empty() && status.0 == 0xc000000f) | ||
| 69 | ) => | ||
| 70 | { | ||
| 71 | break; | ||
| 72 | } | ||
| 73 | Err(error) => return Err(error), | ||
| 74 | }; | ||
| 75 | for entry in decode(&response.output_buffer)? { | ||
| 76 | if entries | ||
| 77 | .insert(entry.name, (entry.size, entry.attributes)) | ||
| 78 | .is_some() | ||
| 79 | { | ||
| 80 | return Err(io::ErrorKind::ResourceBusy.into()); | ||
| 81 | } | ||
| 82 | if entries.len() | ||
| 83 | - usize::from(entries.contains_key(".")) | ||
| 84 | - usize::from(entries.contains_key("..")) | ||
| 85 | > limit | ||
| 86 | { | ||
| 87 | return Err(io::ErrorKind::FileTooLarge.into()); | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | file.close()?; | ||
| 92 | Ok(entries | ||
| 93 | .into_iter() | ||
| 94 | .filter(|(name, _)| name != "." && name != "..") | ||
| 95 | .map(|(name, (size, attributes))| DirectoryEntry { | ||
| 96 | name, | ||
| 97 | size, | ||
| 98 | attributes, | ||
| 99 | }) | ||
| 100 | .collect()) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | fn decode(mut bytes: &[u8]) -> io::Result<Vec<DirectoryEntry>> { | ||
| 105 | if bytes.len() > 65536 { | ||
| 106 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 107 | } | ||
| 108 | let mut entries = Vec::new(); | ||
| 109 | loop { | ||
| 110 | if bytes.len() < 64 { | ||
| 111 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 112 | } | ||
| 113 | let next = u32::from_le_bytes(bytes[..4].try_into().unwrap()) as usize; | ||
| 114 | let length = u32::from_le_bytes(bytes[60..64].try_into().unwrap()) as usize; | ||
| 115 | let end = 64usize | ||
| 116 | .checked_add(length) | ||
| 117 | .ok_or(io::ErrorKind::InvalidData)?; | ||
| 118 | if length == 0 | ||
| 119 | || !length.is_multiple_of(2) | ||
| 120 | || end > bytes.len() | ||
| 121 | || (next != 0 && (next < end || !next.is_multiple_of(8) || next >= bytes.len())) | ||
| 122 | || (next == 0 && bytes.len() - end > 7) | ||
| 123 | { | ||
| 124 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 125 | } | ||
| 126 | let units: Vec<_> = bytes[64..end] | ||
| 127 | .chunks_exact(2) | ||
| 128 | .map(|unit| u16::from_le_bytes([unit[0], unit[1]])) | ||
| 129 | .collect(); | ||
| 130 | let name = String::from_utf16(&units).map_err(|_| io::ErrorKind::InvalidData)?; | ||
| 131 | if name.contains(['\0', '/', '\\']) { | ||
| 132 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 133 | } | ||
| 134 | let size = i64::from_le_bytes(bytes[40..48].try_into().unwrap()); | ||
| 135 | entries.push(DirectoryEntry { | ||
| 136 | name, | ||
| 137 | size: size.try_into().map_err(|_| io::ErrorKind::InvalidData)?, | ||
| 138 | attributes: u32::from_le_bytes(bytes[56..60].try_into().unwrap()), | ||
| 139 | }); | ||
| 140 | if next == 0 { | ||
| 141 | return Ok(entries); | ||
| 142 | } | ||
| 143 | bytes = &bytes[next..]; | ||
| 144 | } | ||
| 145 | } | ||
| 146 | |||
| 147 | #[cfg(test)] | ||
| 148 | mod tests { | ||
| 149 | use super::*; | ||
| 150 | |||
| 151 | fn record(name: &str, size: u64, attributes: u32) -> Vec<u8> { | ||
| 152 | let mut bytes = vec![0; 64]; | ||
| 153 | bytes[40..48].copy_from_slice(&size.to_le_bytes()); | ||
| 154 | bytes[56..60].copy_from_slice(&attributes.to_le_bytes()); | ||
| 155 | let name: Vec<_> = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); | ||
| 156 | bytes[60..64].copy_from_slice(&(name.len() as u32).to_le_bytes()); | ||
| 157 | bytes.extend(name); | ||
| 158 | bytes | ||
| 159 | } | ||
| 160 | |||
| 161 | #[test] | ||
| 162 | fn directory_records_preserve_names_sizes_and_attributes() { | ||
| 163 | let mut bytes = Vec::new(); | ||
| 164 | let mut expected = Vec::new(); | ||
| 165 | for i in 0..300u32 { | ||
| 166 | let name = format!("Section {i} 🦀 e\u{301}.one"); | ||
| 167 | let size = u64::from(i) * 100_000_000; | ||
| 168 | let attributes = if i % 3 == 0 { 0x410 } else { 0x20 }; | ||
| 169 | let mut entry = record(&name, size, attributes); | ||
| 170 | if i != 299 { | ||
| 171 | entry.resize(entry.len().next_multiple_of(8), 0xa5); | ||
| 172 | let length = entry.len() as u32; | ||
| 173 | entry[..4].copy_from_slice(&length.to_le_bytes()); | ||
| 174 | } | ||
| 175 | bytes.extend(entry); | ||
| 176 | expected.push(DirectoryEntry { | ||
| 177 | name, | ||
| 178 | size, | ||
| 179 | attributes, | ||
| 180 | }); | ||
| 181 | } | ||
| 182 | assert_eq!(decode(&bytes).unwrap(), expected); | ||
| 183 | for end in 0..bytes.len() { | ||
| 184 | assert!(decode(&bytes[..end]).is_err(), "accepted prefix {end}"); | ||
| 185 | } | ||
| 186 | } | ||
| 187 | |||
| 188 | #[test] | ||
| 189 | fn invalid_directory_records_never_become_partial_results() { | ||
| 190 | assert!(decode(&vec![0; 65537]).is_err()); | ||
| 191 | for name in ["", "bad\0name", "a/b", "a\\b"] { | ||
| 192 | assert!(decode(&record(name, 0, 0)).is_err()); | ||
| 193 | } | ||
| 194 | let valid = record("a.one", 12, 0x20); | ||
| 195 | for (at, value) in [ | ||
| 196 | (0, 8), | ||
| 197 | (0, 65), | ||
| 198 | (0, 72), | ||
| 199 | (0, u32::MAX), | ||
| 200 | (60, 0), | ||
| 201 | (60, 1), | ||
| 202 | (60, u32::MAX), | ||
| 203 | (44, u32::MAX), | ||
| 204 | ] { | ||
| 205 | let mut bytes = valid.clone(); | ||
| 206 | bytes[at..at + 4].copy_from_slice(&value.to_le_bytes()); | ||
| 207 | assert!(decode(&bytes).is_err(), "offset={at} value={value}"); | ||
| 208 | } | ||
| 209 | let mut bytes = valid.clone(); | ||
| 210 | bytes[64..66].copy_from_slice(&0xd800u16.to_le_bytes()); | ||
| 211 | assert!(decode(&bytes).is_err()); | ||
| 212 | let mut bytes = valid; | ||
| 213 | bytes.extend_from_slice(&[0; 8]); | ||
| 214 | assert!(decode(&bytes).is_err()); | ||
| 215 | for name in [".", ".."] { | ||
| 216 | assert_eq!(decode(&record(name, 0, 0x10)).unwrap()[0].name, name); | ||
| 217 | } | ||
| 218 | } | ||
| 219 | } | ||
crates/notebook/src/smb/mod.rs created+518| ... | @@ -0,0 +1,518 @@ | ||
| 1 | //! Blocking SMB access to OneNote sections, table-of-contents files and payloads. | ||
| 2 | |||
| 3 | use onestore::{CommitError, CommitIo, CommitState, ExGuid}; | ||
| 4 | use smb2::{ | ||
| 5 | Session, Tree, | ||
| 6 | client::connection::{Connection, NegotiatedParams}, | ||
| 7 | msg::{ | ||
| 8 | close::{CloseRequest, CloseResponse}, | ||
| 9 | create::{ | ||
| 10 | CreateDisposition, CreateRequest, CreateResponse, ImpersonationLevel, ShareAccess, | ||
| 11 | }, | ||
| 12 | flush::{FlushRequest, FlushResponse}, | ||
| 13 | lock::{LockElement, LockRequest, LockResponse}, | ||
| 14 | query_info::{InfoType, QueryInfoRequest, QueryInfoResponse}, | ||
| 15 | read::{ReadRequest, ReadResponse}, | ||
| 16 | write::{WriteRequest, WriteResponse}, | ||
| 17 | }, | ||
| 18 | pack::{Pack, ReadCursor, Unpack}, | ||
| 19 | types::{ | ||
| 20 | Command, CreditCharge, Dialect, FileId, OplockLevel, | ||
| 21 | flags::{Capabilities, FileAccessMask}, | ||
| 22 | }, | ||
| 23 | }; | ||
| 24 | use std::{io, ops::Range, sync::Mutex, time::Duration}; | ||
| 25 | use tokio::runtime::{Handle, Runtime}; | ||
| 26 | |||
| 27 | mod directory; | ||
| 28 | mod remote; | ||
| 29 | pub use directory::DirectoryEntry; | ||
| 30 | pub use remote::SmbRemote; | ||
| 31 | |||
| 32 | #[derive(Default)] | ||
| 33 | pub struct Credentials<'a> { | ||
| 34 | pub username: &'a str, | ||
| 35 | pub password: &'a str, | ||
| 36 | pub domain: &'a str, | ||
| 37 | } | ||
| 38 | |||
| 39 | /// Blocking connection with no automatic request replay or cached file contents. | ||
| 40 | /// Call from a background thread outside a Tokio runtime. | ||
| 41 | /// Paths are relative to the share; both `/` and `\` are separators. | ||
| 42 | pub struct Client { | ||
| 43 | connection: Mutex<Option<Connection>>, | ||
| 44 | tree: Tree, | ||
| 45 | timeout: Duration, | ||
| 46 | runtime: Mutex<Option<Runtime>>, | ||
| 47 | } | ||
| 48 | |||
| 49 | impl Client { | ||
| 50 | pub fn connect( | ||
| 51 | address: &str, | ||
| 52 | share: &str, | ||
| 53 | credentials: Credentials<'_>, | ||
| 54 | timeout: Duration, | ||
| 55 | ) -> io::Result<Self> { | ||
| 56 | if Handle::try_current().is_ok() || timeout.is_zero() { | ||
| 57 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 58 | } | ||
| 59 | let runtime = tokio::runtime::Builder::new_multi_thread() | ||
| 60 | .worker_threads(1) | ||
| 61 | .enable_all() | ||
| 62 | .build()?; | ||
| 63 | let (connection, tree) = runtime | ||
| 64 | .block_on(async { | ||
| 65 | tokio::time::timeout(timeout, async { | ||
| 66 | let mut connection = Connection::connect(address, timeout).await?; | ||
| 67 | connection.set_compression_requested(false); | ||
| 68 | connection.negotiate().await?; | ||
| 69 | Session::setup( | ||
| 70 | &mut connection, | ||
| 71 | credentials.username, | ||
| 72 | credentials.password, | ||
| 73 | credentials.domain, | ||
| 74 | ) | ||
| 75 | .await?; | ||
| 76 | let tree = Tree::connect(&mut connection, share).await?; | ||
| 77 | Ok::<_, smb2::Error>((connection, tree)) | ||
| 78 | }) | ||
| 79 | .await | ||
| 80 | }) | ||
| 81 | .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))? | ||
| 82 | .map_err(io::Error::other)?; | ||
| 83 | Ok(Self { | ||
| 84 | connection: Mutex::new(Some(connection)), | ||
| 85 | tree, | ||
| 86 | timeout, | ||
| 87 | runtime: Mutex::new(Some(runtime)), | ||
| 88 | }) | ||
| 89 | } | ||
| 90 | |||
| 91 | fn retire(&self) { | ||
| 92 | if let Some(connection) = self | ||
| 93 | .connection | ||
| 94 | .lock() | ||
| 95 | .unwrap_or_else(|error| error.into_inner()) | ||
| 96 | .take() | ||
| 97 | { | ||
| 98 | connection.mark_dead(); | ||
| 99 | } | ||
| 100 | if let Some(runtime) = self | ||
| 101 | .runtime | ||
| 102 | .lock() | ||
| 103 | .unwrap_or_else(|error| error.into_inner()) | ||
| 104 | .take() | ||
| 105 | { | ||
| 106 | runtime.shutdown_background(); | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | fn request<T: Unpack>(&self, command: Command, body: impl Pack) -> io::Result<T> { | ||
| 111 | self.request_with(command, |_| (body, CreditCharge(1))) | ||
| 112 | } | ||
| 113 | |||
| 114 | fn request_with<T: Unpack, B: Pack>( | ||
| 115 | &self, | ||
| 116 | command: Command, | ||
| 117 | prepare: impl FnOnce(&Connection) -> (B, CreditCharge), | ||
| 118 | ) -> io::Result<T> { | ||
| 119 | if Handle::try_current().is_ok() { | ||
| 120 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 121 | } | ||
| 122 | let connection = self | ||
| 123 | .connection | ||
| 124 | .lock() | ||
| 125 | .map_err(|_| io::ErrorKind::Other)? | ||
| 126 | .clone() | ||
| 127 | .ok_or(io::ErrorKind::NotConnected)?; | ||
| 128 | let frame = { | ||
| 129 | let runtime = self.runtime.lock().map_err(|_| io::ErrorKind::Other)?; | ||
| 130 | let (body, charge) = prepare(&connection); | ||
| 131 | runtime | ||
| 132 | .as_ref() | ||
| 133 | .ok_or(io::ErrorKind::NotConnected)? | ||
| 134 | .block_on(async { | ||
| 135 | tokio::time::timeout( | ||
| 136 | self.timeout, | ||
| 137 | connection.execute_with_credits( | ||
| 138 | command, | ||
| 139 | &body, | ||
| 140 | Some(self.tree.tree_id), | ||
| 141 | charge, | ||
| 142 | ), | ||
| 143 | ) | ||
| 144 | .await | ||
| 145 | }) | ||
| 146 | }; | ||
| 147 | let frame = frame | ||
| 148 | .map_err(|_| io::Error::from(io::ErrorKind::TimedOut)) | ||
| 149 | .and_then(|result| result.map_err(io::Error::other)) | ||
| 150 | .inspect_err(|_| self.retire())?; | ||
| 151 | if frame.header.command != command { | ||
| 152 | self.retire(); | ||
| 153 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 154 | } | ||
| 155 | if frame.header.status.0 != 0 { | ||
| 156 | let kind = match frame.header.status.0 { | ||
| 157 | 0xc0000043 | 0xc0000054 | 0xc0000055 => io::ErrorKind::WouldBlock, | ||
| 158 | 0xc0000011 => io::ErrorKind::UnexpectedEof, | ||
| 159 | 0xc0000034 | 0xc000003a => io::ErrorKind::NotFound, | ||
| 160 | 0xc0000022 => io::ErrorKind::PermissionDenied, | ||
| 161 | 0xc0000103 => io::ErrorKind::NotADirectory, | ||
| 162 | _ => io::ErrorKind::Other, | ||
| 163 | }; | ||
| 164 | return Err(io::Error::new( | ||
| 165 | kind, | ||
| 166 | smb2::Error::Protocol { | ||
| 167 | status: frame.header.status, | ||
| 168 | command, | ||
| 169 | }, | ||
| 170 | )); | ||
| 171 | } | ||
| 172 | T::unpack(&mut ReadCursor::new(&frame.body)).map_err(|error| { | ||
| 173 | self.retire(); | ||
| 174 | io::Error::new(io::ErrorKind::InvalidData, error) | ||
| 175 | }) | ||
| 176 | } | ||
| 177 | |||
| 178 | fn open(&self, path: &str, write: bool) -> io::Result<File<'_>> { | ||
| 179 | self.open_shared(path, write, if write { 5 } else { 7 }) | ||
| 180 | } | ||
| 181 | |||
| 182 | fn open_shared(&self, path: &str, write: bool, sharing: u32) -> io::Result<File<'_>> { | ||
| 183 | if path.is_empty() || path.contains('\0') || path.encode_utf16().count() > 32767 { | ||
| 184 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 185 | } | ||
| 186 | let response: CreateResponse = self.request( | ||
| 187 | Command::Create, | ||
| 188 | CreateRequest { | ||
| 189 | requested_oplock_level: OplockLevel::None, | ||
| 190 | impersonation_level: ImpersonationLevel::Impersonation, | ||
| 191 | desired_access: FileAccessMask::new(if write { 0xc0000000 } else { 0x80000000 }), | ||
| 192 | file_attributes: 0, | ||
| 193 | share_access: ShareAccess(sharing), | ||
| 194 | create_disposition: CreateDisposition::FileOpen, | ||
| 195 | create_options: 0x42, | ||
| 196 | name: smb2::encode_path(&path.replace('\\', "/")), | ||
| 197 | create_contexts: Vec::new(), | ||
| 198 | }, | ||
| 199 | )?; | ||
| 200 | Ok(File { | ||
| 201 | client: self, | ||
| 202 | id: Some(response.file_id), | ||
| 203 | }) | ||
| 204 | } | ||
| 205 | |||
| 206 | /// Reads a bounded external payload while denying concurrent writes and deletion. | ||
| 207 | /// Empty files succeed; limits, sharing contention and failed close return no payload. | ||
| 208 | pub fn read_asset(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 209 | let mut file = self.open_shared(path, false, 1)?; | ||
| 210 | let mut bytes = Vec::new(); | ||
| 211 | let mut block = [0; 65536]; | ||
| 212 | loop { | ||
| 213 | let count = (limit - bytes.len()).min(block.len() - 1) + 1; | ||
| 214 | let read = file.read_at( | ||
| 215 | u64::try_from(bytes.len()).map_err(|_| io::ErrorKind::InvalidInput)?, | ||
| 216 | &mut block[..count], | ||
| 217 | )?; | ||
| 218 | if read == 0 { | ||
| 219 | break; | ||
| 220 | } | ||
| 221 | bytes.extend_from_slice(&block[..read]); | ||
| 222 | if bytes.len() > limit { | ||
| 223 | return Err(io::ErrorKind::FileTooLarge.into()); | ||
| 224 | } | ||
| 225 | } | ||
| 226 | file.close()?; | ||
| 227 | Ok(bytes) | ||
| 228 | } | ||
| 229 | |||
| 230 | /// Reads one bounded, consistent snapshot; contention returns WouldBlock. | ||
| 231 | pub fn read(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 232 | self.read_with(path, |file| { | ||
| 233 | onestore::read_snapshot(|offset, output| file.read_at(offset, output), limit) | ||
| 234 | }) | ||
| 235 | } | ||
| 236 | |||
| 237 | /// Reads consistent storage, including encrypted or incomplete document graphs. | ||
| 238 | /// Storage validation alone does not establish edit readiness. | ||
| 239 | pub fn read_storage(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 240 | self.read_with(path, |file| { | ||
| 241 | onestore::read_storage_snapshot(|offset, output| file.read_at(offset, output), limit) | ||
| 242 | }) | ||
| 243 | } | ||
| 244 | |||
| 245 | fn read_with( | ||
| 246 | &self, | ||
| 247 | path: &str, | ||
| 248 | snapshot: impl FnOnce(&mut File<'_>) -> io::Result<Option<Vec<u8>>>, | ||
| 249 | ) -> io::Result<Vec<u8>> { | ||
| 250 | let mut file = self.open(path, false)?.coordinate(path, false)?; | ||
| 251 | let result = snapshot(&mut file) | ||
| 252 | .and_then(|snapshot| snapshot.ok_or_else(|| io::ErrorKind::WouldBlock.into())); | ||
| 253 | let closed = file.close(); | ||
| 254 | let snapshot = result?; | ||
| 255 | closed?; | ||
| 256 | Ok(snapshot) | ||
| 257 | } | ||
| 258 | |||
| 259 | pub fn commit_text( | ||
| 260 | &self, | ||
| 261 | path: &str, | ||
| 262 | source: &[u8], | ||
| 263 | space: ExGuid, | ||
| 264 | object: ExGuid, | ||
| 265 | range: Range<u32>, | ||
| 266 | replacement: &str, | ||
| 267 | ) -> Result<(), CommitError> { | ||
| 268 | self.commit(path, |file| { | ||
| 269 | onestore::commit_text(file, source, space, object, range, replacement) | ||
| 270 | }) | ||
| 271 | } | ||
| 272 | |||
| 273 | pub fn commit_property_bytes( | ||
| 274 | &self, | ||
| 275 | path: &str, | ||
| 276 | source: &[u8], | ||
| 277 | space: ExGuid, | ||
| 278 | object: ExGuid, | ||
| 279 | property: u32, | ||
| 280 | value: &[u8], | ||
| 281 | ) -> Result<(), CommitError> { | ||
| 282 | self.commit(path, |file| { | ||
| 283 | onestore::commit_property_bytes(file, source, space, object, property, value) | ||
| 284 | }) | ||
| 285 | } | ||
| 286 | |||
| 287 | /// Publishes a prepared edit using the same native writer coordination as text commits. | ||
| 288 | pub fn commit_prepared( | ||
| 289 | &self, | ||
| 290 | path: &str, | ||
| 291 | edit: &onestore::PreparedEdit<'_>, | ||
| 292 | ) -> Result<(), CommitError> { | ||
| 293 | self.commit(path, |file| edit.commit(file)) | ||
| 294 | } | ||
| 295 | |||
| 296 | /// Confirms an observed snapshot's durability under native writer coordination. | ||
| 297 | pub fn confirm_snapshot(&self, path: &str, source: &[u8]) -> Result<(), CommitError> { | ||
| 298 | self.commit(path, |file| onestore::confirm_snapshot(file, source)) | ||
| 299 | } | ||
| 300 | |||
| 301 | fn commit( | ||
| 302 | &self, | ||
| 303 | path: &str, | ||
| 304 | operation: impl FnOnce(&mut File<'_>) -> Result<(), CommitError>, | ||
| 305 | ) -> Result<(), CommitError> { | ||
| 306 | let mut file = self | ||
| 307 | .open(path, true) | ||
| 308 | .and_then(|file| file.coordinate(path, true)) | ||
| 309 | .map_err(|error| CommitError { | ||
| 310 | state: CommitState::NotCommitted, | ||
| 311 | error, | ||
| 312 | })?; | ||
| 313 | let result = operation(&mut file); | ||
| 314 | let closed = file.close(); | ||
| 315 | result?; | ||
| 316 | closed.map_err(|error| CommitError { | ||
| 317 | state: CommitState::Committed, | ||
| 318 | error, | ||
| 319 | }) | ||
| 320 | } | ||
| 321 | } | ||
| 322 | |||
| 323 | impl Drop for Client { | ||
| 324 | fn drop(&mut self) { | ||
| 325 | self.retire(); | ||
| 326 | } | ||
| 327 | } | ||
| 328 | |||
| 329 | struct File<'a> { | ||
| 330 | client: &'a Client, | ||
| 331 | id: Option<FileId>, | ||
| 332 | } | ||
| 333 | impl File<'_> { | ||
| 334 | fn coordinate(self, path: &str, write: bool) -> io::Result<Self> { | ||
| 335 | self.lock(0xfffffffb, 0x11)?; | ||
| 336 | if write { | ||
| 337 | self.lock(0xfffffffd, 0x12)?; | ||
| 338 | } | ||
| 339 | let current = self.client.open(path, false)?; | ||
| 340 | let same = self.identity()? == current.identity()?; | ||
| 341 | current.close()?; | ||
| 342 | if !same { | ||
| 343 | return Err(io::ErrorKind::ResourceBusy.into()); | ||
| 344 | } | ||
| 345 | Ok(self) | ||
| 346 | } | ||
| 347 | |||
| 348 | fn lock(&self, offset: u64, flags: u32) -> io::Result<()> { | ||
| 349 | let _: LockResponse = self.client.request( | ||
| 350 | Command::Lock, | ||
| 351 | LockRequest { | ||
| 352 | file_id: self.id.unwrap(), | ||
| 353 | lock_sequence: 0, | ||
| 354 | locks: vec![LockElement { | ||
| 355 | offset, | ||
| 356 | length: 1, | ||
| 357 | flags, | ||
| 358 | }], | ||
| 359 | }, | ||
| 360 | )?; | ||
| 361 | Ok(()) | ||
| 362 | } | ||
| 363 | |||
| 364 | fn identity(&self) -> io::Result<(u64, u32)> { | ||
| 365 | let index: QueryInfoResponse = self.client.request( | ||
| 366 | Command::QueryInfo, | ||
| 367 | QueryInfoRequest { | ||
| 368 | info_type: InfoType::File, | ||
| 369 | file_info_class: 6, | ||
| 370 | output_buffer_length: 8, | ||
| 371 | additional_information: 0, | ||
| 372 | flags: 0, | ||
| 373 | file_id: self.id.unwrap(), | ||
| 374 | input_buffer: Vec::new(), | ||
| 375 | }, | ||
| 376 | )?; | ||
| 377 | let index = u64::from_le_bytes( | ||
| 378 | index | ||
| 379 | .output_buffer | ||
| 380 | .try_into() | ||
| 381 | .map_err(|_| io::ErrorKind::InvalidData)?, | ||
| 382 | ); | ||
| 383 | if index == 0 { | ||
| 384 | return Err(io::ErrorKind::Unsupported.into()); | ||
| 385 | } | ||
| 386 | let volume: QueryInfoResponse = self.client.request( | ||
| 387 | Command::QueryInfo, | ||
| 388 | QueryInfoRequest { | ||
| 389 | info_type: InfoType::Filesystem, | ||
| 390 | file_info_class: 1, | ||
| 391 | output_buffer_length: 1024, | ||
| 392 | additional_information: 0, | ||
| 393 | flags: 0, | ||
| 394 | file_id: self.id.unwrap(), | ||
| 395 | input_buffer: Vec::new(), | ||
| 396 | }, | ||
| 397 | )?; | ||
| 398 | let serial = volume | ||
| 399 | .output_buffer | ||
| 400 | .get(8..12) | ||
| 401 | .ok_or(io::ErrorKind::InvalidData)?; | ||
| 402 | Ok((index, u32::from_le_bytes(serial.try_into().unwrap()))) | ||
| 403 | } | ||
| 404 | |||
| 405 | fn close(mut self) -> io::Result<()> { | ||
| 406 | self.release() | ||
| 407 | } | ||
| 408 | |||
| 409 | fn release(&mut self) -> io::Result<()> { | ||
| 410 | if let Some(file_id) = self.id.take() { | ||
| 411 | let result: io::Result<CloseResponse> = self | ||
| 412 | .client | ||
| 413 | .request(Command::Close, CloseRequest { file_id, flags: 0 }); | ||
| 414 | if result.is_err() { | ||
| 415 | self.client.retire(); | ||
| 416 | } | ||
| 417 | result?; | ||
| 418 | } | ||
| 419 | Ok(()) | ||
| 420 | } | ||
| 421 | } | ||
| 422 | impl Drop for File<'_> { | ||
| 423 | fn drop(&mut self) { | ||
| 424 | let _ = self.release(); | ||
| 425 | } | ||
| 426 | } | ||
| 427 | fn read_size(params: &NegotiatedParams, credits: u16, requested: usize) -> usize { | ||
| 428 | let budget = if params.dialect != Dialect::Smb2_0_2 | ||
| 429 | && params.capabilities.contains(Capabilities::LARGE_MTU) | ||
| 430 | { | ||
| 431 | usize::from(credits.max(1)) * 65536 | ||
| 432 | } else { | ||
| 433 | 65536 | ||
| 434 | }; | ||
| 435 | requested | ||
| 436 | .min(params.max_read_size as usize) | ||
| 437 | .min(budget) | ||
| 438 | .min(1024 * 1024) | ||
| 439 | } | ||
| 440 | |||
| 441 | impl CommitIo for File<'_> { | ||
| 442 | fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> { | ||
| 443 | if output.is_empty() { | ||
| 444 | return Ok(0); | ||
| 445 | } | ||
| 446 | let mut size = 0; | ||
| 447 | let response: io::Result<ReadResponse> = | ||
| 448 | self.client.request_with(Command::Read, |connection| { | ||
| 449 | size = read_size( | ||
| 450 | &connection | ||
| 451 | .params() | ||
| 452 | .expect("connected SMB session is negotiated"), | ||
| 453 | connection.credits(), | ||
| 454 | output.len(), | ||
| 455 | ); | ||
| 456 | ( | ||
| 457 | ReadRequest { | ||
| 458 | file_id: self.id.unwrap(), | ||
| 459 | offset, | ||
| 460 | length: size as u32, | ||
| 461 | minimum_count: 1, | ||
| 462 | flags: 0, | ||
| 463 | padding: 0, | ||
| 464 | channel: 0, | ||
| 465 | remaining_bytes: 0, | ||
| 466 | read_channel_info: Vec::new(), | ||
| 467 | }, | ||
| 468 | CreditCharge(size.div_ceil(65536) as u16), | ||
| 469 | ) | ||
| 470 | }); | ||
| 471 | let response = match response { | ||
| 472 | Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(0), | ||
| 473 | result => result?, | ||
| 474 | }; | ||
| 475 | if response.data.len() > size { | ||
| 476 | self.client.retire(); | ||
| 477 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 478 | } | ||
| 479 | output[..response.data.len()].copy_from_slice(&response.data); | ||
| 480 | Ok(response.data.len()) | ||
| 481 | } | ||
| 482 | fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> { | ||
| 483 | if bytes.is_empty() { | ||
| 484 | return Ok(0); | ||
| 485 | } | ||
| 486 | let size = bytes.len().min(65536); | ||
| 487 | let response: WriteResponse = self.client.request( | ||
| 488 | Command::Write, | ||
| 489 | WriteRequest { | ||
| 490 | file_id: self.id.unwrap(), | ||
| 491 | offset, | ||
| 492 | data: bytes[..size].to_vec(), | ||
| 493 | data_offset: 112, | ||
| 494 | flags: 1, | ||
| 495 | channel: 0, | ||
| 496 | remaining_bytes: 0, | ||
| 497 | write_channel_info_offset: 0, | ||
| 498 | write_channel_info_length: 0, | ||
| 499 | }, | ||
| 500 | )?; | ||
| 501 | if response.count as usize > size { | ||
| 502 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 503 | } | ||
| 504 | Ok(response.count as usize) | ||
| 505 | } | ||
| 506 | fn flush(&mut self) -> io::Result<()> { | ||
| 507 | let _: FlushResponse = self.client.request( | ||
| 508 | Command::Flush, | ||
| 509 | FlushRequest { | ||
| 510 | file_id: self.id.unwrap(), | ||
| 511 | }, | ||
| 512 | )?; | ||
| 513 | Ok(()) | ||
| 514 | } | ||
| 515 | } | ||
| 516 | |||
| 517 | #[cfg(test)] | ||
| 518 | mod tests; | ||
crates/notebook/src/smb/remote.rs created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | use super::Client; | ||
| 2 | use crate::Remote; | ||
| 3 | use onestore::{CommitError, PreparedEdit}; | ||
| 4 | use std::io; | ||
| 5 | |||
| 6 | /// Binds every reconciliation operation to one share-relative file and read limit. | ||
| 7 | /// Connection loss retires the client; reconnect before subsequent sync attempts. | ||
| 8 | pub struct SmbRemote { | ||
| 9 | client: Client, | ||
| 10 | path: String, | ||
| 11 | limit: usize, | ||
| 12 | } | ||
| 13 | |||
| 14 | impl SmbRemote { | ||
| 15 | pub fn new(client: Client, path: impl Into<String>, limit: usize) -> Self { | ||
| 16 | Self { | ||
| 17 | client, | ||
| 18 | path: path.into(), | ||
| 19 | limit, | ||
| 20 | } | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | impl Remote for SmbRemote { | ||
| 25 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 26 | self.client.read(&self.path, self.limit) | ||
| 27 | } | ||
| 28 | |||
| 29 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 30 | self.client.commit_prepared(&self.path, edit) | ||
| 31 | } | ||
| 32 | |||
| 33 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 34 | self.client.confirm_snapshot(&self.path, snapshot) | ||
| 35 | } | ||
| 36 | } | ||
crates/notebook/src/smb/tests.rs created+507| ... | @@ -0,0 +1,507 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{ | ||
| 3 | RevisionIndex, Store, | ||
| 4 | document::{Document, Kind}, | ||
| 5 | }; | ||
| 6 | use std::{ | ||
| 7 | fs, | ||
| 8 | time::{Instant, SystemTime, UNIX_EPOCH}, | ||
| 9 | }; | ||
| 10 | |||
| 11 | mod faults; | ||
| 12 | |||
| 13 | #[test] | ||
| 14 | #[ignore = "requires an owned Samba directory and ONESTORE_SMB_DIRECTORY_ORACLE from its local filesystem"] | ||
| 15 | fn live_directory() { | ||
| 16 | let client = client(); | ||
| 17 | let bytes = fs::read(std::env::var("ONESTORE_SMB_DIRECTORY_ORACLE").unwrap()).unwrap(); | ||
| 18 | let oracle: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); | ||
| 19 | let root = oracle["path"].as_str().unwrap(); | ||
| 20 | let expected = oracle["entries"].as_array().unwrap(); | ||
| 21 | let entries = client.read_dir(root, expected.len()).unwrap(); | ||
| 22 | assert_eq!(entries.len(), expected.len()); | ||
| 23 | for expected in expected { | ||
| 24 | let entry = entries | ||
| 25 | .iter() | ||
| 26 | .find(|entry| entry.name == expected["name"]) | ||
| 27 | .unwrap(); | ||
| 28 | let directory = expected["directory"].as_bool().unwrap(); | ||
| 29 | assert_eq!(entry.attributes & 0x10 != 0, directory, "{}", entry.name); | ||
| 30 | if !directory { | ||
| 31 | assert_eq!( | ||
| 32 | entry.size, | ||
| 33 | expected["size"].as_u64().unwrap(), | ||
| 34 | "{}", | ||
| 35 | entry.name | ||
| 36 | ); | ||
| 37 | } | ||
| 38 | } | ||
| 39 | assert_eq!( | ||
| 40 | client.read_dir(root, entries.len() - 1).unwrap_err().kind(), | ||
| 41 | io::ErrorKind::FileTooLarge | ||
| 42 | ); | ||
| 43 | assert_eq!(client.read_dir(root, entries.len()).unwrap(), entries); | ||
| 44 | assert!( | ||
| 45 | client | ||
| 46 | .read_dir(&format!("{root}/empty"), 0) | ||
| 47 | .unwrap() | ||
| 48 | .is_empty() | ||
| 49 | ); | ||
| 50 | assert_eq!( | ||
| 51 | client | ||
| 52 | .read_dir(&format!("{root}/missing"), 1) | ||
| 53 | .unwrap_err() | ||
| 54 | .kind(), | ||
| 55 | io::ErrorKind::NotFound | ||
| 56 | ); | ||
| 57 | assert_eq!( | ||
| 58 | client | ||
| 59 | .read_dir(&format!("{root}/file.one"), 1) | ||
| 60 | .unwrap_err() | ||
| 61 | .kind(), | ||
| 62 | io::ErrorKind::NotADirectory | ||
| 63 | ); | ||
| 64 | assert_eq!( | ||
| 65 | client | ||
| 66 | .read_dir(&format!("{root}/denied"), 1) | ||
| 67 | .unwrap_err() | ||
| 68 | .kind(), | ||
| 69 | io::ErrorKind::PermissionDenied | ||
| 70 | ); | ||
| 71 | assert!( | ||
| 72 | client | ||
| 73 | .read_dir("", 10_000) | ||
| 74 | .unwrap() | ||
| 75 | .iter() | ||
| 76 | .any(|entry| entry.name == root) | ||
| 77 | ); | ||
| 78 | } | ||
| 79 | |||
| 80 | #[test] | ||
| 81 | #[ignore = "requires an owned Samba directory through a proxy that interrupts a later directory page or close"] | ||
| 82 | fn live_directory_interruption() { | ||
| 83 | let client = client(); | ||
| 84 | let root = std::env::var("ONESTORE_SMB_DIRECTORY").unwrap(); | ||
| 85 | let started = Instant::now(); | ||
| 86 | assert!(client.read_dir(&root, 10_000).is_err()); | ||
| 87 | assert!(started.elapsed() < Duration::from_secs(10)); | ||
| 88 | assert_eq!( | ||
| 89 | client.read_dir(&root, 10_000).unwrap_err().kind(), | ||
| 90 | io::ErrorKind::NotConnected | ||
| 91 | ); | ||
| 92 | } | ||
| 93 | |||
| 94 | fn client() -> Client { | ||
| 95 | Client::connect( | ||
| 96 | &std::env::var("ONESTORE_SMB_LAB").unwrap(), | ||
| 97 | "agent", | ||
| 98 | Credentials::default(), | ||
| 99 | Duration::from_secs(5), | ||
| 100 | ) | ||
| 101 | .unwrap() | ||
| 102 | } | ||
| 103 | fn create(client: &Client, path: &str, bytes: &[u8]) { | ||
| 104 | let response: CreateResponse = client | ||
| 105 | .request( | ||
| 106 | Command::Create, | ||
| 107 | CreateRequest { | ||
| 108 | requested_oplock_level: OplockLevel::None, | ||
| 109 | impersonation_level: ImpersonationLevel::Impersonation, | ||
| 110 | desired_access: FileAccessMask::new(0xc0000000), | ||
| 111 | file_attributes: 0, | ||
| 112 | share_access: ShareAccess(7), | ||
| 113 | create_disposition: CreateDisposition::FileCreate, | ||
| 114 | create_options: 0x42, | ||
| 115 | name: path.to_owned(), | ||
| 116 | create_contexts: Vec::new(), | ||
| 117 | }, | ||
| 118 | ) | ||
| 119 | .unwrap(); | ||
| 120 | let mut file = File { | ||
| 121 | client, | ||
| 122 | id: Some(response.file_id), | ||
| 123 | }; | ||
| 124 | let mut offset = 0; | ||
| 125 | while offset < bytes.len() { | ||
| 126 | offset += file.write_at(offset as u64, &bytes[offset..]).unwrap(); | ||
| 127 | } | ||
| 128 | file.flush().unwrap(); | ||
| 129 | file.close().unwrap(); | ||
| 130 | } | ||
| 131 | fn text(bytes: &[u8]) -> (ExGuid, ExGuid, String) { | ||
| 132 | let store = Store::parse(bytes).unwrap(); | ||
| 133 | assert!(store.checksum_mismatches.is_empty()); | ||
| 134 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 135 | index.validate_current().unwrap(); | ||
| 136 | let doc = Document::parse(&index).unwrap(); | ||
| 137 | doc.spaces | ||
| 138 | .iter() | ||
| 139 | .find_map(|(sid, space)| { | ||
| 140 | let revision = space.active().unwrap(); | ||
| 141 | revision | ||
| 142 | .nodes | ||
| 143 | .iter() | ||
| 144 | .find_map(|(oid, node)| match &node.kind { | ||
| 145 | Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())), | ||
| 146 | _ => None, | ||
| 147 | }) | ||
| 148 | }) | ||
| 149 | .unwrap() | ||
| 150 | } | ||
| 151 | #[test] | ||
| 152 | #[ignore = "requires disposable Samba and an existing ONESTORE_SMB_EVIDENCE directory"] | ||
| 153 | fn live_coordination() { | ||
| 154 | let output = std::env::var("ONESTORE_SMB_EVIDENCE").unwrap(); | ||
| 155 | let writer = client(); | ||
| 156 | let path = format!( | ||
| 157 | "adapter-{}.one", | ||
| 158 | SystemTime::now() | ||
| 159 | .duration_since(UNIX_EPOCH) | ||
| 160 | .unwrap() | ||
| 161 | .as_nanos() | ||
| 162 | ); | ||
| 163 | let source = onestore::create_section(&path, "Before café 🦀", "Fixture").unwrap(); | ||
| 164 | create(&writer, &path, &source); | ||
| 165 | assert_eq!(writer.read(&path, 1 << 20).unwrap(), source); | ||
| 166 | let readers: Vec<_> = (0..12).map(|_| client()).collect(); | ||
| 167 | let mut held: Vec<_> = readers | ||
| 168 | .iter() | ||
| 169 | .map(|client| { | ||
| 170 | client | ||
| 171 | .open(&path, false) | ||
| 172 | .unwrap() | ||
| 173 | .coordinate(&path, false) | ||
| 174 | .unwrap() | ||
| 175 | }) | ||
| 176 | .collect(); | ||
| 177 | let (sid, oid, before) = text(&source); | ||
| 178 | let replacement = "After café 🦀"; | ||
| 179 | writer | ||
| 180 | .commit_text( | ||
| 181 | &path, | ||
| 182 | &source, | ||
| 183 | sid, | ||
| 184 | oid, | ||
| 185 | 0..before.encode_utf16().count() as u32, | ||
| 186 | replacement, | ||
| 187 | ) | ||
| 188 | .unwrap(); | ||
| 189 | let after = writer.read(&path, 1 << 20).unwrap(); | ||
| 190 | assert_eq!(text(&after).2, replacement); | ||
| 191 | for file in &mut held { | ||
| 192 | let snapshot = onestore::read_snapshot(|offset, out| file.read_at(offset, out), 1 << 20) | ||
| 193 | .unwrap() | ||
| 194 | .unwrap(); | ||
| 195 | assert_eq!(snapshot, after); | ||
| 196 | } | ||
| 197 | let error = writer | ||
| 198 | .commit_text(&path, &source, sid, oid, 0..1, "X") | ||
| 199 | .unwrap_err(); | ||
| 200 | assert_eq!(error.state, CommitState::NotCommitted); | ||
| 201 | assert_eq!(error.error.kind(), io::ErrorKind::ResourceBusy); | ||
| 202 | assert_eq!(writer.read(&path, 1 << 20).unwrap(), after); | ||
| 203 | drop(held); | ||
| 204 | |||
| 205 | let stale = writer.open(&path, true).unwrap(); | ||
| 206 | let maintenance = client(); | ||
| 207 | let guard = maintenance.open(&path, false).unwrap(); | ||
| 208 | let _: LockResponse = maintenance | ||
| 209 | .request( | ||
| 210 | Command::Lock, | ||
| 211 | LockRequest { | ||
| 212 | file_id: guard.id.unwrap(), | ||
| 213 | lock_sequence: 0, | ||
| 214 | locks: vec![ | ||
| 215 | LockElement { | ||
| 216 | offset: 0xfffffffc, | ||
| 217 | length: 1, | ||
| 218 | flags: 0x12, | ||
| 219 | }, | ||
| 220 | LockElement { | ||
| 221 | offset: 0xffffeffc, | ||
| 222 | length: 4096, | ||
| 223 | flags: 0x12, | ||
| 224 | }, | ||
| 225 | ], | ||
| 226 | }, | ||
| 227 | ) | ||
| 228 | .unwrap(); | ||
| 229 | let replacement_path = format!("{path}.replacement"); | ||
| 230 | create(&maintenance, &replacement_path, &source); | ||
| 231 | let mut connection = maintenance | ||
| 232 | .connection | ||
| 233 | .lock() | ||
| 234 | .unwrap() | ||
| 235 | .as_ref() | ||
| 236 | .unwrap() | ||
| 237 | .clone(); | ||
| 238 | maintenance | ||
| 239 | .runtime | ||
| 240 | .lock() | ||
| 241 | .unwrap() | ||
| 242 | .as_ref() | ||
| 243 | .unwrap() | ||
| 244 | .block_on( | ||
| 245 | maintenance | ||
| 246 | .tree | ||
| 247 | .rename(&mut connection, &path, &format!("{path}.old")), | ||
| 248 | ) | ||
| 249 | .unwrap(); | ||
| 250 | maintenance | ||
| 251 | .runtime | ||
| 252 | .lock() | ||
| 253 | .unwrap() | ||
| 254 | .as_ref() | ||
| 255 | .unwrap() | ||
| 256 | .block_on( | ||
| 257 | maintenance | ||
| 258 | .tree | ||
| 259 | .rename(&mut connection, &replacement_path, &path), | ||
| 260 | ) | ||
| 261 | .unwrap(); | ||
| 262 | drop(connection); | ||
| 263 | guard.close().unwrap(); | ||
| 264 | let error = stale.coordinate(&path, true).err().unwrap(); | ||
| 265 | assert_eq!(error.kind(), io::ErrorKind::ResourceBusy); | ||
| 266 | assert_eq!(writer.read(&path, 1 << 20).unwrap(), source); | ||
| 267 | |||
| 268 | let retiring = client(); | ||
| 269 | let file = retiring | ||
| 270 | .open(&path, true) | ||
| 271 | .unwrap() | ||
| 272 | .coordinate(&path, true) | ||
| 273 | .unwrap(); | ||
| 274 | retiring.retire(); | ||
| 275 | assert_eq!( | ||
| 276 | retiring.read(&path, 1 << 20).unwrap_err().kind(), | ||
| 277 | io::ErrorKind::NotConnected | ||
| 278 | ); | ||
| 279 | drop(file); | ||
| 280 | let deadline = Instant::now() + Duration::from_secs(5); | ||
| 281 | loop { | ||
| 282 | match writer | ||
| 283 | .open(&path, true) | ||
| 284 | .and_then(|file| file.coordinate(&path, true)) | ||
| 285 | { | ||
| 286 | Ok(file) => { | ||
| 287 | file.close().unwrap(); | ||
| 288 | break; | ||
| 289 | } | ||
| 290 | Err(error) | ||
| 291 | if error.kind() == io::ErrorKind::WouldBlock && Instant::now() < deadline => | ||
| 292 | { | ||
| 293 | std::thread::sleep(Duration::from_millis(10)) | ||
| 294 | } | ||
| 295 | Err(error) => panic!("retired connection retained locks: {error}"), | ||
| 296 | } | ||
| 297 | } | ||
| 298 | |||
| 299 | let mut unfinished = writer | ||
| 300 | .open(&path, true) | ||
| 301 | .unwrap() | ||
| 302 | .coordinate(&path, true) | ||
| 303 | .unwrap(); | ||
| 304 | assert_eq!( | ||
| 305 | unfinished | ||
| 306 | .write_at(source.len() as u64, b"unpublished") | ||
| 307 | .unwrap(), | ||
| 308 | 11 | ||
| 309 | ); | ||
| 310 | unfinished.flush().unwrap(); | ||
| 311 | unfinished.close().unwrap(); | ||
| 312 | let snapshot = writer.read(&path, 1 << 20).unwrap(); | ||
| 313 | assert_eq!(snapshot.len(), source.len() + 11); | ||
| 314 | assert_eq!(text(&snapshot).2, before); | ||
| 315 | writer | ||
| 316 | .commit_text( | ||
| 317 | &path, | ||
| 318 | &snapshot, | ||
| 319 | sid, | ||
| 320 | oid, | ||
| 321 | 0..before.encode_utf16().count() as u32, | ||
| 322 | "Recovered café 🦀", | ||
| 323 | ) | ||
| 324 | .unwrap(); | ||
| 325 | let recovered = writer.read(&path, 1 << 20).unwrap(); | ||
| 326 | assert_eq!(text(&recovered).2, "Recovered café 🦀"); | ||
| 327 | let spare = client(); | ||
| 328 | tokio::runtime::Builder::new_current_thread() | ||
| 329 | .build() | ||
| 330 | .unwrap() | ||
| 331 | .block_on(async { | ||
| 332 | drop(spare); | ||
| 333 | }); | ||
| 334 | fs::write(std::path::Path::new(&output).join("source.one"), &source).unwrap(); | ||
| 335 | fs::write(std::path::Path::new(&output).join("committed.one"), &after).unwrap(); | ||
| 336 | fs::write( | ||
| 337 | std::path::Path::new(&output).join("recovered.one"), | ||
| 338 | &recovered, | ||
| 339 | ) | ||
| 340 | .unwrap(); | ||
| 341 | println!( | ||
| 342 | "{}", | ||
| 343 | serde_json::json!({"path":path,"readers":12,"committed_while_readers_held":true,"fresh_reads":12,"stale_snapshot_rejected":true,"replaced_handle_rejected":true,"retirement_releases_locks":true,"unpublished_tail_recovered":true,"drop_inside_runtime":true}) | ||
| 344 | ); | ||
| 345 | } | ||
| 346 | |||
| 347 | #[test] | ||
| 348 | #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] | ||
| 349 | fn live_storage_inspection() { | ||
| 350 | let reader = client(); | ||
| 351 | for (index, fixture) in [ | ||
| 352 | "native-encrypted/encrypted-01/notebook/synthetic.one", | ||
| 353 | "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one", | ||
| 354 | ] | ||
| 355 | .into_iter() | ||
| 356 | .enumerate() | ||
| 357 | { | ||
| 358 | let source = fs::read(format!("../../corpus/{fixture}")).unwrap(); | ||
| 359 | let path = format!( | ||
| 360 | "inspection-{index}-{}.one", | ||
| 361 | SystemTime::now() | ||
| 362 | .duration_since(UNIX_EPOCH) | ||
| 363 | .unwrap() | ||
| 364 | .as_nanos() | ||
| 365 | ); | ||
| 366 | create(&reader, &path, &source); | ||
| 367 | assert_eq!(reader.read_storage(&path, source.len()).unwrap(), source); | ||
| 368 | assert_eq!( | ||
| 369 | reader.read(&path, source.len()).unwrap_err().kind(), | ||
| 370 | io::ErrorKind::WouldBlock | ||
| 371 | ); | ||
| 372 | let maintenance = client(); | ||
| 373 | let guard = maintenance.open(&path, false).unwrap(); | ||
| 374 | guard.lock(0xfffffffb, 0x12).unwrap(); | ||
| 375 | assert_eq!( | ||
| 376 | reader.read_storage(&path, source.len()).unwrap_err().kind(), | ||
| 377 | io::ErrorKind::WouldBlock | ||
| 378 | ); | ||
| 379 | guard.close().unwrap(); | ||
| 380 | assert_eq!(reader.read_storage(&path, source.len()).unwrap(), source); | ||
| 381 | } | ||
| 382 | } | ||
| 383 | |||
| 384 | #[test] | ||
| 385 | #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] | ||
| 386 | fn live_assets() { | ||
| 387 | let reader = client(); | ||
| 388 | let writer = client(); | ||
| 389 | for size in [0, 1, 65535, 65536, 65537, 1048577] { | ||
| 390 | let bytes: Vec<_> = (0..size).map(|index| (index % 251) as u8).collect(); | ||
| 391 | let path = format!( | ||
| 392 | "asset-{size}-{}.onebin", | ||
| 393 | SystemTime::now() | ||
| 394 | .duration_since(UNIX_EPOCH) | ||
| 395 | .unwrap() | ||
| 396 | .as_nanos() | ||
| 397 | ); | ||
| 398 | create(&writer, &path, &bytes); | ||
| 399 | assert_eq!(reader.read_asset(&path, size).unwrap(), bytes); | ||
| 400 | if size != 0 { | ||
| 401 | assert_eq!( | ||
| 402 | reader.read_asset(&path, size - 1).unwrap_err().kind(), | ||
| 403 | io::ErrorKind::FileTooLarge | ||
| 404 | ); | ||
| 405 | assert_eq!(reader.read_asset(&path, size).unwrap(), bytes); | ||
| 406 | } | ||
| 407 | let writing = writer.open(&path, true).unwrap(); | ||
| 408 | assert_eq!( | ||
| 409 | reader.read_asset(&path, size).unwrap_err().kind(), | ||
| 410 | io::ErrorKind::WouldBlock | ||
| 411 | ); | ||
| 412 | writing.close().unwrap(); | ||
| 413 | let asset = reader.open_shared(&path, false, 1).unwrap(); | ||
| 414 | assert_eq!( | ||
| 415 | writer.open(&path, true).err().unwrap().kind(), | ||
| 416 | io::ErrorKind::WouldBlock | ||
| 417 | ); | ||
| 418 | let other = writer.open_shared(&path, false, 1).unwrap(); | ||
| 419 | other.close().unwrap(); | ||
| 420 | asset.close().unwrap(); | ||
| 421 | writer.open(&path, true).unwrap().close().unwrap(); | ||
| 422 | assert_eq!(reader.read_asset(&path, size).unwrap(), bytes); | ||
| 423 | } | ||
| 424 | assert_eq!( | ||
| 425 | reader | ||
| 426 | .read_asset("absent-payload.onebin", 0) | ||
| 427 | .unwrap_err() | ||
| 428 | .kind(), | ||
| 429 | io::ErrorKind::NotFound | ||
| 430 | ); | ||
| 431 | } | ||
| 432 | |||
| 433 | #[test] | ||
| 434 | #[ignore = "requires an owned Samba fixture and maintenance controller"] | ||
| 435 | fn live_reader_hold() { | ||
| 436 | let client = client(); | ||
| 437 | let path = std::env::var("ONESTORE_SMB_PATH").unwrap(); | ||
| 438 | let output = std::path::PathBuf::from(std::env::var("ONESTORE_SMB_HOLD").unwrap()); | ||
| 439 | assert!(!output.join("ready").exists() && !output.join("release").exists()); | ||
| 440 | let mut file = client | ||
| 441 | .open(&path, false) | ||
| 442 | .unwrap() | ||
| 443 | .coordinate(&path, false) | ||
| 444 | .unwrap(); | ||
| 445 | fs::write(output.join("ready"), b"held").unwrap(); | ||
| 446 | let deadline = Instant::now() + Duration::from_secs(300); | ||
| 447 | let mut accepted = 0; | ||
| 448 | let mut retries = 0; | ||
| 449 | while !output.join("release").exists() { | ||
| 450 | assert!( | ||
| 451 | Instant::now() < deadline, | ||
| 452 | "maintenance controller timed out" | ||
| 453 | ); | ||
| 454 | match onestore::read_snapshot(|offset, out| file.read_at(offset, out), 256 << 20).unwrap() { | ||
| 455 | Some(_) => accepted += 1, | ||
| 456 | None => retries += 1, | ||
| 457 | } | ||
| 458 | std::thread::sleep(Duration::from_millis(5)); | ||
| 459 | } | ||
| 460 | file.close().unwrap(); | ||
| 461 | assert!(accepted > 0); | ||
| 462 | fs::write( | ||
| 463 | output.join("released.json"), | ||
| 464 | serde_json::to_vec(&serde_json::json!({"accepted": accepted, "retries": retries})).unwrap(), | ||
| 465 | ) | ||
| 466 | .unwrap(); | ||
| 467 | } | ||
| 468 | |||
| 469 | #[test] | ||
| 470 | fn read_limits_respect_negotiation_and_available_credits() { | ||
| 471 | for dialect in Dialect::ALL { | ||
| 472 | for large_mtu in [false, true] { | ||
| 473 | for max_read_size in [65536, 65537, 131072, 1048576, u32::MAX] { | ||
| 474 | let params = NegotiatedParams { | ||
| 475 | dialect: *dialect, | ||
| 476 | max_read_size, | ||
| 477 | max_write_size: 65536, | ||
| 478 | max_transact_size: 65536, | ||
| 479 | server_guid: Default::default(), | ||
| 480 | signing_required: false, | ||
| 481 | capabilities: Capabilities(if large_mtu { | ||
| 482 | Capabilities::LARGE_MTU | ||
| 483 | } else { | ||
| 484 | 0 | ||
| 485 | }), | ||
| 486 | gmac_negotiated: false, | ||
| 487 | cipher: None, | ||
| 488 | compression_supported: false, | ||
| 489 | }; | ||
| 490 | for credits in [0, 1, 2, 3, 15, 16, 17, u16::MAX] { | ||
| 491 | for requested in [1, 1024, 65535, 65536, 65537, 131072, 1048576, usize::MAX] { | ||
| 492 | let size = read_size(&params, credits, requested); | ||
| 493 | assert!(size > 0 && size <= requested && size <= max_read_size as usize); | ||
| 494 | assert!(size <= 1048576); | ||
| 495 | assert!(size.div_ceil(65536) <= usize::from(credits.max(1))); | ||
| 496 | if *dialect == Dialect::Smb2_0_2 || !large_mtu { | ||
| 497 | assert!(size <= 65536); | ||
| 498 | } else if credits >= 16 && max_read_size >= 1048576 && requested >= 1048576 | ||
| 499 | { | ||
| 500 | assert_eq!(size, 1048576); | ||
| 501 | } | ||
| 502 | } | ||
| 503 | } | ||
| 504 | } | ||
| 505 | } | ||
| 506 | } | ||
| 507 | } | ||
crates/notebook/src/smb/tests/faults.rs created+511| ... | @@ -0,0 +1,511 @@ | ||
| 1 | use super::*; | ||
| 2 | use serde_json::{Value, json}; | ||
| 3 | use std::{collections::BTreeMap, path::Path, process::Child}; | ||
| 4 | |||
| 5 | #[derive(Clone)] | ||
| 6 | struct Snapshot { | ||
| 7 | content: BTreeMap<ExGuid, String>, | ||
| 8 | modified: BTreeMap<(ExGuid, ExGuid), u32>, | ||
| 9 | } | ||
| 10 | |||
| 11 | fn snapshot(bytes: &[u8]) -> Snapshot { | ||
| 12 | let store = Store::parse(bytes).unwrap(); | ||
| 13 | assert!(store.checksum_mismatches.is_empty()); | ||
| 14 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 15 | index.validate_current().unwrap(); | ||
| 16 | Document::parse(&index).unwrap(); | ||
| 17 | let mut content = BTreeMap::new(); | ||
| 18 | let mut modified = BTreeMap::new(); | ||
| 19 | for (sid, space) in &index.spaces { | ||
| 20 | let revision = index | ||
| 21 | .resolve(*sid, space.labels[&(ExGuid::default(), 1)]) | ||
| 22 | .unwrap(); | ||
| 23 | let mut objects = BTreeMap::new(); | ||
| 24 | for (oid, object) in &revision.objects { | ||
| 25 | if let Some(onestore::FileDataReference::Internal(guid)) = | ||
| 26 | object.file_reference().unwrap() | ||
| 27 | { | ||
| 28 | store.file_data(guid).unwrap(); | ||
| 29 | } | ||
| 30 | let data = match object.data { | ||
| 31 | onestore::ObjectData::Properties(bytes) => { | ||
| 32 | let mut properties = onestore::PropertySets::parse(bytes).unwrap(); | ||
| 33 | for property in &mut properties.sets[0] { | ||
| 34 | if property.id == 0x14001d7a { | ||
| 35 | let onestore::Value::Bytes(value) = property.value else { | ||
| 36 | panic!() | ||
| 37 | }; | ||
| 38 | assert!( | ||
| 39 | modified | ||
| 40 | .insert( | ||
| 41 | (*sid, *oid), | ||
| 42 | u32::from_le_bytes(value.try_into().unwrap()) | ||
| 43 | ) | ||
| 44 | .is_none() | ||
| 45 | ); | ||
| 46 | property.value = onestore::Value::Bytes(&[0; 4]); | ||
| 47 | } | ||
| 48 | } | ||
| 49 | format!("{properties:?}") | ||
| 50 | } | ||
| 51 | other => format!("{other:?}"), | ||
| 52 | }; | ||
| 53 | objects.insert( | ||
| 54 | *oid, | ||
| 55 | ( | ||
| 56 | object.jcid, | ||
| 57 | object.reference_count, | ||
| 58 | data, | ||
| 59 | format!("{:?}", object.references().unwrap()), | ||
| 60 | ), | ||
| 61 | ); | ||
| 62 | } | ||
| 63 | content.insert(*sid, format!("{:?} {objects:?}", revision.roots)); | ||
| 64 | } | ||
| 65 | Snapshot { content, modified } | ||
| 66 | } | ||
| 67 | |||
| 68 | fn stamp() -> u32 { | ||
| 69 | (SystemTime::now() | ||
| 70 | .duration_since(UNIX_EPOCH) | ||
| 71 | .unwrap() | ||
| 72 | .as_secs() | ||
| 73 | - 315532800) | ||
| 74 | .try_into() | ||
| 75 | .unwrap() | ||
| 76 | } | ||
| 77 | |||
| 78 | fn published( | ||
| 79 | actual: &Snapshot, | ||
| 80 | old: &Snapshot, | ||
| 81 | new: &Snapshot, | ||
| 82 | time: std::ops::RangeInclusive<u32>, | ||
| 83 | ) -> bool { | ||
| 84 | if actual.content == old.content { | ||
| 85 | assert_eq!( | ||
| 86 | actual.modified, old.modified, | ||
| 87 | "Unpublished modification time changed" | ||
| 88 | ); | ||
| 89 | return false; | ||
| 90 | } | ||
| 91 | assert_eq!( | ||
| 92 | actual.content, new.content, | ||
| 93 | "Partial or unexpected publication" | ||
| 94 | ); | ||
| 95 | assert!(actual.modified.keys().eq(new.modified.keys())); | ||
| 96 | for (key, value) in &actual.modified { | ||
| 97 | if old.modified.get(key) != new.modified.get(key) { | ||
| 98 | assert!( | ||
| 99 | time.contains(value), | ||
| 100 | "Modification time outside the commit interval" | ||
| 101 | ); | ||
| 102 | } else { | ||
| 103 | assert_eq!( | ||
| 104 | *value, new.modified[key], | ||
| 105 | "Unrelated modification time changed" | ||
| 106 | ); | ||
| 107 | } | ||
| 108 | } | ||
| 109 | true | ||
| 110 | } | ||
| 111 | |||
| 112 | #[test] | ||
| 113 | fn publication_oracle_bounds_changed_timestamps_and_preserves_others() { | ||
| 114 | let id = ExGuid::default(); | ||
| 115 | let other = ExGuid { n: 1, ..id }; | ||
| 116 | let old = Snapshot { | ||
| 117 | content: BTreeMap::from([(id, "old".into())]), | ||
| 118 | modified: BTreeMap::from([((id, id), 10), ((id, other), 7)]), | ||
| 119 | }; | ||
| 120 | let new = Snapshot { | ||
| 121 | content: BTreeMap::from([(id, "new".into())]), | ||
| 122 | modified: BTreeMap::from([((id, id), 20), ((id, other), 7)]), | ||
| 123 | }; | ||
| 124 | let mut actual = new.clone(); | ||
| 125 | actual.modified.insert((id, id), 30); | ||
| 126 | assert!(published(&actual, &old, &new, 25..=35)); | ||
| 127 | assert!(!published(&old, &old, &new, 25..=35)); | ||
| 128 | for (key, value) in [((id, id), 24), ((id, id), 36), ((id, other), 30)] { | ||
| 129 | let mut changed = actual.clone(); | ||
| 130 | changed.modified.insert(key, value); | ||
| 131 | assert!(std::panic::catch_unwind(|| published(&changed, &old, &new, 25..=35)).is_err()); | ||
| 132 | } | ||
| 133 | actual.content = old.content.clone(); | ||
| 134 | assert!(std::panic::catch_unwind(|| published(&actual, &old, &new, 25..=35)).is_err()); | ||
| 135 | } | ||
| 136 | |||
| 137 | struct Proxy(Child); | ||
| 138 | impl Drop for Proxy { | ||
| 139 | fn drop(&mut self) { | ||
| 140 | let _ = self.0.kill(); | ||
| 141 | let _ = self.0.wait(); | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | fn records(output: &Path) -> Vec<Value> { | ||
| 146 | let data = fs::read_to_string(output.join("proxy.jsonl")).unwrap(); | ||
| 147 | data.rsplit_once('\n') | ||
| 148 | .map_or("", |(complete, _)| complete) | ||
| 149 | .lines() | ||
| 150 | .map(|line| serde_json::from_str(line).unwrap()) | ||
| 151 | .collect() | ||
| 152 | } | ||
| 153 | |||
| 154 | fn configure(output: &Path, state: Value) -> usize { | ||
| 155 | fs::write(output.join("control.tmp"), state.to_string()).unwrap(); | ||
| 156 | fs::rename(output.join("control.tmp"), output.join("control.json")).unwrap(); | ||
| 157 | let deadline = Instant::now() + Duration::from_secs(5); | ||
| 158 | loop { | ||
| 159 | let events = records(output); | ||
| 160 | if let Some(index) = events | ||
| 161 | .iter() | ||
| 162 | .rposition(|event| event.get("control") == Some(&state)) | ||
| 163 | { | ||
| 164 | return index + 1; | ||
| 165 | } | ||
| 166 | assert!( | ||
| 167 | Instant::now() < deadline, | ||
| 168 | "proxy did not acknowledge its control state" | ||
| 169 | ); | ||
| 170 | std::thread::sleep(Duration::from_millis(5)); | ||
| 171 | } | ||
| 172 | } | ||
| 173 | |||
| 174 | fn proxy(output: &Path) -> (Proxy, String) { | ||
| 175 | let address = std::env::var("ONESTORE_SMB_LAB").unwrap(); | ||
| 176 | let (host, port) = address.rsplit_once(':').unwrap(); | ||
| 177 | let mut proxy = Proxy( | ||
| 178 | std::process::Command::new("python3") | ||
| 179 | .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tools/smb-proxy.py")) | ||
| 180 | .arg(output.join("control.json")) | ||
| 181 | .args(["--port", "0", "--server", host, "--server-port", port]) | ||
| 182 | .stdout(fs::File::create(output.join("proxy.jsonl")).unwrap()) | ||
| 183 | .stderr(fs::File::create(output.join("proxy.stderr")).unwrap()) | ||
| 184 | .spawn() | ||
| 185 | .unwrap(), | ||
| 186 | ); | ||
| 187 | let deadline = Instant::now() + Duration::from_secs(5); | ||
| 188 | let port = loop { | ||
| 189 | if let Some(port) = records(output) | ||
| 190 | .iter() | ||
| 191 | .find_map(|event| event["listening"].as_u64()) | ||
| 192 | { | ||
| 193 | break port; | ||
| 194 | } | ||
| 195 | assert!(proxy.0.try_wait().unwrap().is_none()); | ||
| 196 | assert!(Instant::now() < deadline, "proxy did not start"); | ||
| 197 | std::thread::sleep(Duration::from_millis(5)); | ||
| 198 | }; | ||
| 199 | (proxy, format!("127.0.0.1:{port}")) | ||
| 200 | } | ||
| 201 | |||
| 202 | #[test] | ||
| 203 | #[ignore = "requires an owned Samba share and a new ONESTORE_SMB_EVIDENCE directory"] | ||
| 204 | fn live_asset_loss() { | ||
| 205 | let output = std::path::PathBuf::from(std::env::var("ONESTORE_SMB_EVIDENCE").unwrap()); | ||
| 206 | fs::create_dir(&output).unwrap(); | ||
| 207 | let (_proxy, address) = proxy(&output); | ||
| 208 | let observer = client(); | ||
| 209 | let bytes: Vec<_> = (0..1048577).map(|index| (index % 251) as u8).collect(); | ||
| 210 | let path = format!( | ||
| 211 | "asset-loss-{}.onebin", | ||
| 212 | SystemTime::now() | ||
| 213 | .duration_since(UNIX_EPOCH) | ||
| 214 | .unwrap() | ||
| 215 | .as_nanos() | ||
| 216 | ); | ||
| 217 | create(&observer, &path, &bytes); | ||
| 218 | for (command, occurrence) in [(8, 1), (8, 9), (8, 17), (8, 18), (6, 1)] { | ||
| 219 | for direction in ["request", "response"] { | ||
| 220 | let reader = Client::connect( | ||
| 221 | &address, | ||
| 222 | "agent", | ||
| 223 | Credentials::default(), | ||
| 224 | Duration::from_secs(5), | ||
| 225 | ) | ||
| 226 | .unwrap(); | ||
| 227 | // Response occurrences count only replies with the selected status. | ||
| 228 | let begin = configure( | ||
| 229 | &output, | ||
| 230 | json!({"cut":command,"occurrence":if command == 8 && occurrence == 18 && direction == "response" { 1 } else { occurrence }, | ||
| 231 | "direction":direction,"status":if command == 8 && occurrence == 18 { "0xc0000011" } else { "0x0" }}), | ||
| 232 | ); | ||
| 233 | assert!( | ||
| 234 | reader.read_asset(&path, bytes.len()).is_err(), | ||
| 235 | "{command}/{occurrence}/{direction}" | ||
| 236 | ); | ||
| 237 | assert_eq!( | ||
| 238 | reader.read_asset(&path, bytes.len()).unwrap_err().kind(), | ||
| 239 | io::ErrorKind::NotConnected | ||
| 240 | ); | ||
| 241 | assert_eq!( | ||
| 242 | records(&output)[begin..] | ||
| 243 | .iter() | ||
| 244 | .filter(|row| row.get("cut").is_some()) | ||
| 245 | .count(), | ||
| 246 | 1 | ||
| 247 | ); | ||
| 248 | configure( | ||
| 249 | &output, | ||
| 250 | json!({"phase":format!("{command}-{occurrence}-{direction}-reconnected")}), | ||
| 251 | ); | ||
| 252 | let reconnected = Client::connect( | ||
| 253 | &address, | ||
| 254 | "agent", | ||
| 255 | Credentials::default(), | ||
| 256 | Duration::from_secs(5), | ||
| 257 | ) | ||
| 258 | .unwrap(); | ||
| 259 | assert_eq!(reconnected.read_asset(&path, bytes.len()).unwrap(), bytes); | ||
| 260 | } | ||
| 261 | } | ||
| 262 | assert_eq!(observer.read_asset(&path, bytes.len()).unwrap(), bytes); | ||
| 263 | } | ||
| 264 | |||
| 265 | #[test] | ||
| 266 | #[ignore = "requires an owned Samba share and a new ONESTORE_SMB_EVIDENCE directory"] | ||
| 267 | fn live_message_loss() { | ||
| 268 | let output = std::path::PathBuf::from(std::env::var("ONESTORE_SMB_EVIDENCE").unwrap()); | ||
| 269 | fs::create_dir(&output).unwrap(); | ||
| 270 | fs::create_dir(output.join("interrupted")).unwrap(); | ||
| 271 | fs::create_dir(output.join("recovered")).unwrap(); | ||
| 272 | fs::create_dir(output.join("source")).unwrap(); | ||
| 273 | let (_proxy, proxied) = proxy(&output); | ||
| 274 | let observer = client(); | ||
| 275 | let prefix = SystemTime::now() | ||
| 276 | .duration_since(UNIX_EPOCH) | ||
| 277 | .unwrap() | ||
| 278 | .as_nanos(); | ||
| 279 | let fixtures = [ | ||
| 280 | ( | ||
| 281 | "chunked", | ||
| 282 | onestore::create_section("fault.one", "Before café 🦀", "Fault test").unwrap(), | ||
| 283 | ), | ||
| 284 | ( | ||
| 285 | "carry", | ||
| 286 | fs::read( | ||
| 287 | Path::new(env!("CARGO_MANIFEST_DIR")) | ||
| 288 | .join("../../corpus/append/round-01/tx-255/notebook/synthetic.one"), | ||
| 289 | ) | ||
| 290 | .unwrap(), | ||
| 291 | ), | ||
| 292 | ]; | ||
| 293 | let mut results = Vec::new(); | ||
| 294 | for (fixture, source) in fixtures { | ||
| 295 | fs::write( | ||
| 296 | output.join("source").join(format!("{fixture}.one")), | ||
| 297 | &source, | ||
| 298 | ) | ||
| 299 | .unwrap(); | ||
| 300 | let (sid, oid, before) = text(&source); | ||
| 301 | let after = if fixture == "chunked" { | ||
| 302 | "After café 🦀 ".repeat(6000) | ||
| 303 | } else { | ||
| 304 | "After café 🦀".to_owned() | ||
| 305 | }; | ||
| 306 | let suffix = " [reconnected]"; | ||
| 307 | fs::write( | ||
| 308 | output.join(format!("{fixture}-intent.json")), | ||
| 309 | serde_json::to_vec(&json!({"before":before,"after":after,"suffix":suffix})).unwrap(), | ||
| 310 | ) | ||
| 311 | .unwrap(); | ||
| 312 | let old = snapshot(&source); | ||
| 313 | let deadline = Instant::now() + Duration::from_secs(2); | ||
| 314 | while old.modified.values().any(|value| *value >= stamp()) { | ||
| 315 | assert!( | ||
| 316 | Instant::now() < deadline, | ||
| 317 | "source timestamps did not precede the test" | ||
| 318 | ); | ||
| 319 | std::thread::sleep(Duration::from_millis(5)); | ||
| 320 | } | ||
| 321 | let expected = onestore::replace_text( | ||
| 322 | &source, | ||
| 323 | sid, | ||
| 324 | oid, | ||
| 325 | 0..before.encode_utf16().count() as u32, | ||
| 326 | &after, | ||
| 327 | ) | ||
| 328 | .unwrap(); | ||
| 329 | let new = snapshot(&expected); | ||
| 330 | let baseline_path = format!("fault-{prefix}-{fixture}-baseline.one"); | ||
| 331 | create(&observer, &baseline_path, &source); | ||
| 332 | configure(&output, json!({"phase":format!("{fixture}-setup")})); | ||
| 333 | let baseline = Client::connect( | ||
| 334 | &proxied, | ||
| 335 | "agent", | ||
| 336 | Credentials::default(), | ||
| 337 | Duration::from_secs(5), | ||
| 338 | ) | ||
| 339 | .unwrap(); | ||
| 340 | let start = configure(&output, json!({"phase":format!("{fixture}-baseline")})); | ||
| 341 | let began = stamp(); | ||
| 342 | baseline | ||
| 343 | .commit_text( | ||
| 344 | &baseline_path, | ||
| 345 | &source, | ||
| 346 | sid, | ||
| 347 | oid, | ||
| 348 | 0..before.encode_utf16().count() as u32, | ||
| 349 | &after, | ||
| 350 | ) | ||
| 351 | .unwrap(); | ||
| 352 | let events = records(&output); | ||
| 353 | let mut occurrences = BTreeMap::new(); | ||
| 354 | let mut cuts = Vec::new(); | ||
| 355 | for event in &events[start..] { | ||
| 356 | let Some(direction) = event["direction"].as_str() else { | ||
| 357 | continue; | ||
| 358 | }; | ||
| 359 | if event["status"] == "0x103" { | ||
| 360 | continue; | ||
| 361 | } | ||
| 362 | let command = event["command"].as_u64().unwrap(); | ||
| 363 | let status = event["status"].as_str().map(str::to_owned); | ||
| 364 | let count = occurrences | ||
| 365 | .entry((direction.to_owned(), command, status.clone())) | ||
| 366 | .or_insert(0); | ||
| 367 | *count += 1; | ||
| 368 | cuts.push((direction.to_owned(), command, status, *count)); | ||
| 369 | } | ||
| 370 | assert!( | ||
| 371 | cuts.iter() | ||
| 372 | .any(|(direction, command, _, count)| direction == "response" | ||
| 373 | && *command == 7 | ||
| 374 | && *count == 3) | ||
| 375 | ); | ||
| 376 | assert!(published( | ||
| 377 | &snapshot(&observer.read(&baseline_path, 1 << 20).unwrap()), | ||
| 378 | &old, | ||
| 379 | &new, | ||
| 380 | began..=stamp() | ||
| 381 | )); | ||
| 382 | drop(baseline); | ||
| 383 | for (case, (direction, command, status, occurrence)) in cuts.iter().enumerate() { | ||
| 384 | let name = format!("{fixture}-{case:03}"); | ||
| 385 | let path = format!("fault-{prefix}-{name}.one"); | ||
| 386 | create(&observer, &path, &source); | ||
| 387 | configure(&output, json!({"phase":format!("{name}-setup")})); | ||
| 388 | let interrupted = Client::connect( | ||
| 389 | &proxied, | ||
| 390 | "agent", | ||
| 391 | Credentials::default(), | ||
| 392 | Duration::from_secs(5), | ||
| 393 | ) | ||
| 394 | .unwrap(); | ||
| 395 | let start = configure( | ||
| 396 | &output, | ||
| 397 | json!({"phase":name, "direction":direction, "cut":command, "status":status, "occurrence":occurrence}), | ||
| 398 | ); | ||
| 399 | let began = stamp(); | ||
| 400 | let error = interrupted | ||
| 401 | .commit_text( | ||
| 402 | &path, | ||
| 403 | &source, | ||
| 404 | sid, | ||
| 405 | oid, | ||
| 406 | 0..before.encode_utf16().count() as u32, | ||
| 407 | &after, | ||
| 408 | ) | ||
| 409 | .unwrap_err(); | ||
| 410 | assert_eq!( | ||
| 411 | interrupted.read(&path, 1 << 20).unwrap_err().kind(), | ||
| 412 | io::ErrorKind::NotConnected | ||
| 413 | ); | ||
| 414 | let events = records(&output); | ||
| 415 | assert_eq!( | ||
| 416 | events[start..] | ||
| 417 | .iter() | ||
| 418 | .filter(|event| event.get("cut").is_some()) | ||
| 419 | .count(), | ||
| 420 | 1 | ||
| 421 | ); | ||
| 422 | assert!( | ||
| 423 | !events | ||
| 424 | .iter() | ||
| 425 | .any(|event| event.get("trace_error").is_some()) | ||
| 426 | ); | ||
| 427 | let fresh = client(); | ||
| 428 | let deadline = Instant::now() + Duration::from_secs(5); | ||
| 429 | loop { | ||
| 430 | match fresh | ||
| 431 | .open(&path, true) | ||
| 432 | .and_then(|file| file.coordinate(&path, true)) | ||
| 433 | { | ||
| 434 | Ok(file) => { | ||
| 435 | file.close().unwrap(); | ||
| 436 | break; | ||
| 437 | } | ||
| 438 | Err(error) | ||
| 439 | if error.kind() == io::ErrorKind::WouldBlock | ||
| 440 | && Instant::now() < deadline => | ||
| 441 | { | ||
| 442 | std::thread::sleep(Duration::from_millis(5)) | ||
| 443 | } | ||
| 444 | Err(error) => panic!("{name}: retired session retained exclusion: {error}"), | ||
| 445 | } | ||
| 446 | } | ||
| 447 | let saved = fresh.read(&path, 1 << 20).unwrap(); | ||
| 448 | fs::write( | ||
| 449 | output.join("interrupted").join(format!("{name}.one")), | ||
| 450 | &saved, | ||
| 451 | ) | ||
| 452 | .unwrap(); | ||
| 453 | let visible = published(&snapshot(&saved), &old, &new, began..=stamp()); | ||
| 454 | match error.state { | ||
| 455 | CommitState::NotCommitted => assert!(!visible, "{name}"), | ||
| 456 | CommitState::Committed => assert!(visible, "{name}"), | ||
| 457 | CommitState::Unknown => {} | ||
| 458 | } | ||
| 459 | if !visible { | ||
| 460 | fresh | ||
| 461 | .commit_text( | ||
| 462 | &path, | ||
| 463 | &saved, | ||
| 464 | sid, | ||
| 465 | oid, | ||
| 466 | 0..before.encode_utf16().count() as u32, | ||
| 467 | &after, | ||
| 468 | ) | ||
| 469 | .unwrap(); | ||
| 470 | } | ||
| 471 | let saved = fresh.read(&path, 1 << 20).unwrap(); | ||
| 472 | assert!( | ||
| 473 | published(&snapshot(&saved), &old, &new, began..=stamp()), | ||
| 474 | "{name}: replay did not publish" | ||
| 475 | ); | ||
| 476 | let end = after.encode_utf16().count() as u32; | ||
| 477 | fresh | ||
| 478 | .commit_text(&path, &saved, sid, oid, end..end, suffix) | ||
| 479 | .unwrap(); | ||
| 480 | let recovered = fresh.read(&path, 1 << 20).unwrap(); | ||
| 481 | assert_eq!(text(&recovered).2, format!("{after}{suffix}")); | ||
| 482 | fs::write( | ||
| 483 | output.join("recovered").join(format!("{name}.one")), | ||
| 484 | recovered, | ||
| 485 | ) | ||
| 486 | .unwrap(); | ||
| 487 | results.push(json!({"case":name,"path":path,"direction":direction,"command":command,"status":status,"occurrence":occurrence, | ||
| 488 | "state":format!("{:?}",error.state),"visible":if visible {"after"} else {"before"}, | ||
| 489 | "retired_session_rejected":true,"exclusion_released":true,"fresh_commit_succeeded":true})); | ||
| 490 | fs::write( | ||
| 491 | output.join("results.json"), | ||
| 492 | serde_json::to_vec_pretty(&results).unwrap(), | ||
| 493 | ) | ||
| 494 | .unwrap(); | ||
| 495 | } | ||
| 496 | } | ||
| 497 | for state in ["NotCommitted", "Unknown", "Committed"] { | ||
| 498 | assert!(results.iter().any(|result| result["state"] == state)); | ||
| 499 | } | ||
| 500 | assert!( | ||
| 501 | results | ||
| 502 | .iter() | ||
| 503 | .any(|result| result["state"] == "Unknown" && result["visible"] == "before") | ||
| 504 | ); | ||
| 505 | assert!( | ||
| 506 | results | ||
| 507 | .iter() | ||
| 508 | .any(|result| result["state"] == "Unknown" && result["visible"] == "after") | ||
| 509 | ); | ||
| 510 | println!("{} message-loss cases passed", results.len()); | ||
| 511 | } | ||
crates/notebook/src/sync.rs created+671| ... | @@ -0,0 +1,671 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{CommitError, CommitState}; | ||
| 3 | use rusqlite::OptionalExtension; | ||
| 4 | use std::{ | ||
| 5 | collections::BTreeMap, | ||
| 6 | sync::{MutexGuard, TryLockError}, | ||
| 7 | }; | ||
| 8 | |||
| 9 | /// A single remote file with fresh reads and native-compatible guarded publication. | ||
| 10 | /// Errors retain publication state; confirmation compares, flushes, and notifies cached readers. | ||
| 11 | pub trait Remote { | ||
| 12 | fn read(&mut self) -> io::Result<Vec<u8>>; | ||
| 13 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> std::result::Result<(), CommitError>; | ||
| 14 | fn confirm(&mut self, snapshot: &[u8]) -> std::result::Result<(), CommitError>; | ||
| 15 | } | ||
| 16 | |||
| 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 18 | #[repr(i64)] | ||
| 19 | pub enum ConflictKind { | ||
| 20 | TextChanged = 0, | ||
| 21 | TargetUnavailable = 1, | ||
| 22 | UnsupportedEdit = 2, | ||
| 23 | FormattingChanged = 3, | ||
| 24 | StructureChanged = 4, | ||
| 25 | LayoutChanged = 5, | ||
| 26 | ContentChanged = 6, | ||
| 27 | } | ||
| 28 | |||
| 29 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 30 | pub enum EditStatus { | ||
| 31 | Pending, | ||
| 32 | /// Retained publication attempt; this revision alone may be insufficient to confirm it. | ||
| 33 | AwaitingConfirmation { | ||
| 34 | revision: ExGuid, | ||
| 35 | }, | ||
| 36 | Conflict(ConflictKind), | ||
| 37 | /// Revision of the intent's space when its complete effect was confirmed. | ||
| 38 | Published { | ||
| 39 | revision: ExGuid, | ||
| 40 | }, | ||
| 41 | } | ||
| 42 | |||
| 43 | impl Replica { | ||
| 44 | /// Returns a durable receipt or the persisted state of a locally acknowledged edit. | ||
| 45 | pub fn status(&self, id: u64) -> Result<Option<EditStatus>> { | ||
| 46 | let connection = self | ||
| 47 | .connection | ||
| 48 | .lock() | ||
| 49 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 50 | status(&connection, id) | ||
| 51 | } | ||
| 52 | |||
| 53 | /// The last observed remote image, retained alongside the complete local working image. | ||
| 54 | /// Observation alone does not acknowledge any pending edit's remote durability. | ||
| 55 | pub fn remote_snapshot(&self) -> Result<Vec<u8>> { | ||
| 56 | let connection = self | ||
| 57 | .connection | ||
| 58 | .lock() | ||
| 59 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 60 | Ok(connection.query_row("SELECT base FROM replica WHERE id=1", [], |row| row.get(0))?) | ||
| 61 | } | ||
| 62 | |||
| 63 | /// Reconciles one pending edit, or refreshes the working image when the queue is empty. | ||
| 64 | /// Network I/O holds synchronization ownership without holding the cache mutex. | ||
| 65 | /// Uncertain edits are never replayed; retired formatting may confirm its complete observed effect. | ||
| 66 | pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Option<(u64, EditStatus)>> { | ||
| 67 | let _owner = self.sync_owner()?; | ||
| 68 | let snapshot = remote.read().map_err(Error::RemoteIo)?; | ||
| 69 | let identity = validate(&snapshot)?; | ||
| 70 | let (intent, attempted) = { | ||
| 71 | let mut connection = self | ||
| 72 | .connection | ||
| 73 | .lock() | ||
| 74 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 75 | let transaction = | ||
| 76 | connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 77 | let base: Vec<u8> = | ||
| 78 | transaction | ||
| 79 | .query_row("SELECT base FROM replica WHERE id=1", [], |row| row.get(0))?; | ||
| 80 | let base_store = Store::parse(&base)?; | ||
| 81 | if RevisionIndex::parse(&base_store)?.root != identity { | ||
| 82 | return Err(io::Error::new( | ||
| 83 | io::ErrorKind::InvalidInput, | ||
| 84 | "Remote snapshot belongs to another document", | ||
| 85 | ) | ||
| 86 | .into()); | ||
| 87 | } | ||
| 88 | let Some(intent) = pending(&transaction)?.into_iter().next() else { | ||
| 89 | transaction.execute( | ||
| 90 | "UPDATE replica SET base=?1, working=?1 WHERE id=1", | ||
| 91 | [&snapshot], | ||
| 92 | )?; | ||
| 93 | transaction.commit()?; | ||
| 94 | return Ok(None); | ||
| 95 | }; | ||
| 96 | let attempted = transaction | ||
| 97 | .query_row( | ||
| 98 | "SELECT revisions FROM attempt WHERE edit_id=?1", | ||
| 99 | [i64::try_from(intent.id).map_err(io::Error::other)?], | ||
| 100 | |row| row.get::<_, String>(0), | ||
| 101 | ) | ||
| 102 | .optional()?; | ||
| 103 | transaction.execute("UPDATE replica SET base=?1 WHERE id=1", [&snapshot])?; | ||
| 104 | transaction.commit()?; | ||
| 105 | (intent, attempted) | ||
| 106 | }; | ||
| 107 | if let Some(encoded) = attempted { | ||
| 108 | let revisions = attempted_revisions(&encoded, intent.space)?; | ||
| 109 | let mut revision = revisions[&intent.space]; | ||
| 110 | let store = Store::parse(&snapshot)?; | ||
| 111 | let index = RevisionIndex::parse(&store)?; | ||
| 112 | if !revisions.iter().all(|(space, revision)| { | ||
| 113 | index | ||
| 114 | .spaces | ||
| 115 | .get(space) | ||
| 116 | .is_some_and(|space| space.revisions.contains_key(revision)) | ||
| 117 | }) { | ||
| 118 | let satisfied = match &intent.operation { | ||
| 119 | Operation::Format(edit) if revisions.len() == 1 => edit | ||
| 120 | .prepare(&snapshot, intent.space)? | ||
| 121 | .is_ok_and(|prepared| prepared.as_bytes() == snapshot), | ||
| 122 | _ => false, | ||
| 123 | }; | ||
| 124 | if !satisfied { | ||
| 125 | return Ok(Some(( | ||
| 126 | intent.id, | ||
| 127 | EditStatus::AwaitingConfirmation { revision }, | ||
| 128 | ))); | ||
| 129 | } | ||
| 130 | revision = index.active(intent.space)?; | ||
| 131 | } | ||
| 132 | if let Err(error) = remote.confirm(&snapshot) { | ||
| 133 | if error.state == CommitState::Committed { | ||
| 134 | self.acknowledge(intent.id, revision, &snapshot)?; | ||
| 135 | } | ||
| 136 | return Err(error.into()); | ||
| 137 | } | ||
| 138 | self.acknowledge(intent.id, revision, &snapshot)?; | ||
| 139 | return Ok(Some((intent.id, EditStatus::Published { revision }))); | ||
| 140 | } | ||
| 141 | let candidate = match &intent.operation { | ||
| 142 | Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page) | ||
| 143 | .map_err(|_| ConflictKind::StructureChanged), | ||
| 144 | Operation::Pages(edit) => edit.prepare(&snapshot, intent.space)?, | ||
| 145 | Operation::Format(edit) => edit.prepare(&snapshot, intent.space)?, | ||
| 146 | Operation::Split(edit) => edit.prepare(&snapshot, intent.space)?, | ||
| 147 | Operation::Join(edit) => edit.prepare(&snapshot, intent.space)?, | ||
| 148 | Operation::Outline(edit) => edit.prepare(&snapshot, intent.space)?, | ||
| 149 | Operation::Tree(edit) => edit.prepare(&snapshot, intent.space)?, | ||
| 150 | Operation::Insert(insertion) => { | ||
| 151 | PreparedEdit::insert(&snapshot, intent.space, insertion) | ||
| 152 | .map_err(|_| ConflictKind::UnsupportedEdit) | ||
| 153 | } | ||
| 154 | Operation::Text(edit) => paragraph(&snapshot, intent.space, edit.object)? | ||
| 155 | .ok_or(ConflictKind::TargetUnavailable) | ||
| 156 | .and_then(|text| { | ||
| 157 | crate::rebase::rebase(&edit.before, &text, edit.range.clone()) | ||
| 158 | .ok_or(ConflictKind::TextChanged) | ||
| 159 | }) | ||
| 160 | .and_then(|range| { | ||
| 161 | PreparedEdit::text( | ||
| 162 | &snapshot, | ||
| 163 | intent.space, | ||
| 164 | edit.object, | ||
| 165 | range, | ||
| 166 | &edit.replacement, | ||
| 167 | ) | ||
| 168 | .map_err(|_| ConflictKind::UnsupportedEdit) | ||
| 169 | }), | ||
| 170 | }; | ||
| 171 | let prepared = match candidate { | ||
| 172 | Ok(prepared) => prepared, | ||
| 173 | Err(kind) => { | ||
| 174 | let connection = self | ||
| 175 | .connection | ||
| 176 | .lock() | ||
| 177 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 178 | 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])?; | ||
| 179 | return Ok(Some((intent.id, EditStatus::Conflict(kind)))); | ||
| 180 | } | ||
| 181 | }; | ||
| 182 | if prepared.as_bytes() == snapshot { | ||
| 183 | let store = Store::parse(&snapshot)?; | ||
| 184 | let index = RevisionIndex::parse(&store)?; | ||
| 185 | let revision = index.active(intent.space)?; | ||
| 186 | if let Err(error) = remote.confirm(&snapshot) { | ||
| 187 | if error.state == CommitState::Committed { | ||
| 188 | self.acknowledge(intent.id, revision, &snapshot)?; | ||
| 189 | } | ||
| 190 | return Err(error.into()); | ||
| 191 | } | ||
| 192 | self.acknowledge(intent.id, revision, &snapshot)?; | ||
| 193 | return Ok(Some((intent.id, EditStatus::Published { revision }))); | ||
| 194 | } | ||
| 195 | let store = Store::parse(prepared.as_bytes())?; | ||
| 196 | let index = RevisionIndex::parse(&store)?; | ||
| 197 | let revision = index.active(intent.space)?; | ||
| 198 | let before_store = Store::parse(&snapshot)?; | ||
| 199 | let before = RevisionIndex::parse(&before_store)?; | ||
| 200 | let mut revisions: BTreeMap<_, _> = index | ||
| 201 | .spaces | ||
| 202 | .iter() | ||
| 203 | .filter_map(|(sid, space)| { | ||
| 204 | let revision = *space.labels.get(&(ExGuid::default(), 1))?; | ||
| 205 | (before | ||
| 206 | .spaces | ||
| 207 | .get(sid) | ||
| 208 | .and_then(|s| s.labels.get(&(ExGuid::default(), 1))) | ||
| 209 | != Some(&revision)) | ||
| 210 | .then_some((*sid, revision)) | ||
| 211 | }) | ||
| 212 | .collect(); | ||
| 213 | // The receipt's space can be unchanged in a multi-space edit. | ||
| 214 | revisions.insert(intent.space, revision); | ||
| 215 | { | ||
| 216 | let mut connection = self | ||
| 217 | .connection | ||
| 218 | .lock() | ||
| 219 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 220 | let transaction = | ||
| 221 | connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 222 | transaction.execute( | ||
| 223 | "DELETE FROM conflicts WHERE edit_id=?1", | ||
| 224 | [i64::try_from(intent.id).map_err(io::Error::other)?], | ||
| 225 | )?; | ||
| 226 | transaction.execute( | ||
| 227 | "INSERT INTO attempt(id, edit_id, revisions) VALUES (1, ?1, ?2)", | ||
| 228 | params![ | ||
| 229 | i64::try_from(intent.id).map_err(io::Error::other)?, | ||
| 230 | serde_json::to_string(&revisions).map_err(io::Error::other)? | ||
| 231 | ], | ||
| 232 | )?; | ||
| 233 | transaction.commit()?; | ||
| 234 | } | ||
| 235 | match remote.publish(&prepared) { | ||
| 236 | Ok(()) => {} | ||
| 237 | Err(error) if error.state == CommitState::NotCommitted => { | ||
| 238 | let connection = self | ||
| 239 | .connection | ||
| 240 | .lock() | ||
| 241 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 242 | connection.execute( | ||
| 243 | "DELETE FROM attempt WHERE edit_id=?1", | ||
| 244 | [i64::try_from(intent.id).map_err(io::Error::other)?], | ||
| 245 | )?; | ||
| 246 | return Err(error.into()); | ||
| 247 | } | ||
| 248 | Err(error) if error.state == CommitState::Committed => { | ||
| 249 | self.acknowledge(intent.id, revision, prepared.as_bytes())?; | ||
| 250 | return Err(error.into()); | ||
| 251 | } | ||
| 252 | Err(error) => return Err(error.into()), | ||
| 253 | } | ||
| 254 | self.acknowledge(intent.id, revision, prepared.as_bytes())?; | ||
| 255 | Ok(Some((intent.id, EditStatus::Published { revision }))) | ||
| 256 | } | ||
| 257 | |||
| 258 | /// Places the oldest text, formatting or split conflict at a reviewed remote UTF-16 range. | ||
| 259 | /// The requested replacement/attributes, local image, intent ID and later edits are preserved. | ||
| 260 | /// Both supplied images must match `snapshot` and `remote_snapshot`; stale review | ||
| 261 | /// returns `Io(ResourceBusy)`. Uncertain publication attempts cannot be rebased. | ||
| 262 | pub fn rebase_conflict( | ||
| 263 | &self, | ||
| 264 | id: u64, | ||
| 265 | local: &[u8], | ||
| 266 | remote: &[u8], | ||
| 267 | range: Range<u32>, | ||
| 268 | ) -> Result<()> { | ||
| 269 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 270 | Ok(match intent.operation { | ||
| 271 | Operation::Text(mut edit) => { | ||
| 272 | let prepared = PreparedEdit::text( | ||
| 273 | remote, | ||
| 274 | intent.space, | ||
| 275 | edit.object, | ||
| 276 | range.clone(), | ||
| 277 | &edit.replacement, | ||
| 278 | )?; | ||
| 279 | if prepared.as_bytes() == remote { | ||
| 280 | return Err(io::Error::new( | ||
| 281 | io::ErrorKind::InvalidInput, | ||
| 282 | "The selected range already contains the replacement", | ||
| 283 | ) | ||
| 284 | .into()); | ||
| 285 | } | ||
| 286 | edit.before = | ||
| 287 | paragraph(remote, intent.space, edit.object)?.ok_or_else(|| { | ||
| 288 | io::Error::new( | ||
| 289 | io::ErrorKind::InvalidData, | ||
| 290 | "The remote text target is unavailable", | ||
| 291 | ) | ||
| 292 | })?; | ||
| 293 | edit.range = range; | ||
| 294 | Operation::Text(edit) | ||
| 295 | } | ||
| 296 | Operation::Format(edit) => Operation::Format( | ||
| 297 | FormatEdit::capture( | ||
| 298 | remote, | ||
| 299 | intent.space, | ||
| 300 | edit.object, | ||
| 301 | range, | ||
| 302 | &edit.attributes, | ||
| 303 | )? | ||
| 304 | .0, | ||
| 305 | ), | ||
| 306 | Operation::Split(edit) => { | ||
| 307 | if !range.is_empty() { | ||
| 308 | return Err(io::Error::new( | ||
| 309 | io::ErrorKind::InvalidInput, | ||
| 310 | "Choose a single split boundary", | ||
| 311 | ) | ||
| 312 | .into()); | ||
| 313 | } | ||
| 314 | Operation::Split( | ||
| 315 | crate::paragraph::SplitEdit::capture( | ||
| 316 | remote, | ||
| 317 | intent.space, | ||
| 318 | &edit.intent.reposition(range.start), | ||
| 319 | )? | ||
| 320 | .0, | ||
| 321 | ) | ||
| 322 | } | ||
| 323 | Operation::Join(_) => { | ||
| 324 | return Err(io::Error::new( | ||
| 325 | io::ErrorKind::InvalidInput, | ||
| 326 | "Review the paragraph join using rebase_join_conflict", | ||
| 327 | ) | ||
| 328 | .into()); | ||
| 329 | } | ||
| 330 | Operation::Outline(_) => { | ||
| 331 | return Err(io::Error::new( | ||
| 332 | io::ErrorKind::InvalidInput, | ||
| 333 | "Review layout changes using rebase_layout_conflict", | ||
| 334 | ) | ||
| 335 | .into()); | ||
| 336 | } | ||
| 337 | Operation::Tree(_) => { | ||
| 338 | return Err(io::Error::new( | ||
| 339 | io::ErrorKind::InvalidInput, | ||
| 340 | "Review the subtree edit using rebase_tree_conflict", | ||
| 341 | ) | ||
| 342 | .into()); | ||
| 343 | } | ||
| 344 | Operation::Insert(_) => { | ||
| 345 | return Err(io::Error::new( | ||
| 346 | io::ErrorKind::InvalidInput, | ||
| 347 | "Insertion conflicts require a placement, not a text range", | ||
| 348 | ) | ||
| 349 | .into()); | ||
| 350 | } | ||
| 351 | Operation::CreatePage(_) => { | ||
| 352 | return Err(io::Error::new( | ||
| 353 | io::ErrorKind::InvalidInput, | ||
| 354 | "Review page placement using rebase_page_creation_conflict", | ||
| 355 | ) | ||
| 356 | .into()); | ||
| 357 | } | ||
| 358 | Operation::Pages(_) => { | ||
| 359 | return Err(io::Error::new( | ||
| 360 | io::ErrorKind::InvalidInput, | ||
| 361 | "Review page edits using rebase_pages_conflict", | ||
| 362 | ) | ||
| 363 | .into()); | ||
| 364 | } | ||
| 365 | }) | ||
| 366 | }) | ||
| 367 | } | ||
| 368 | |||
| 369 | /// Repositions an unattempted page-creation conflict, retaining dependent object identities. | ||
| 370 | pub fn rebase_page_creation_conflict( | ||
| 371 | &self, | ||
| 372 | id: u64, | ||
| 373 | local: &[u8], | ||
| 374 | remote: &[u8], | ||
| 375 | before: Option<ExGuid>, | ||
| 376 | ) -> Result<()> { | ||
| 377 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 378 | let Operation::CreatePage(page) = intent.operation else { | ||
| 379 | return Err(io::Error::new( | ||
| 380 | io::ErrorKind::InvalidInput, | ||
| 381 | "Select a page-creation conflict", | ||
| 382 | ) | ||
| 383 | .into()); | ||
| 384 | }; | ||
| 385 | let page = page.reposition(before)?; | ||
| 386 | PreparedEdit::create_page(remote, &page)?; | ||
| 387 | Ok(Operation::CreatePage(page)) | ||
| 388 | }) | ||
| 389 | } | ||
| 390 | |||
| 391 | /// Reviews a page batch against both cache images, retaining its page and series identities. | ||
| 392 | pub fn rebase_pages_conflict( | ||
| 393 | &self, | ||
| 394 | id: u64, | ||
| 395 | local: &[u8], | ||
| 396 | remote: &[u8], | ||
| 397 | edits: &[onestore::PageEdit], | ||
| 398 | ) -> Result<()> { | ||
| 399 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 400 | let Operation::Pages(batch) = intent.operation else { | ||
| 401 | return Err(io::Error::new( | ||
| 402 | io::ErrorKind::InvalidInput, | ||
| 403 | "Select a page movement or indentation conflict", | ||
| 404 | ) | ||
| 405 | .into()); | ||
| 406 | }; | ||
| 407 | Ok(Operation::Pages(batch.review( | ||
| 408 | remote, | ||
| 409 | intent.space, | ||
| 410 | edits, | ||
| 411 | )?)) | ||
| 412 | }) | ||
| 413 | } | ||
| 414 | |||
| 415 | /// Reviews a join against both current cache images, retaining its original text identities. | ||
| 416 | /// The surviving text identity must remain the same for dependent edits. | ||
| 417 | pub fn rebase_join_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> { | ||
| 418 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 419 | let Operation::Join(edit) = intent.operation else { | ||
| 420 | return Err(io::Error::new( | ||
| 421 | io::ErrorKind::InvalidInput, | ||
| 422 | "Select a paragraph join conflict", | ||
| 423 | ) | ||
| 424 | .into()); | ||
| 425 | }; | ||
| 426 | Ok(Operation::Join(edit.review(remote, intent.space)?)) | ||
| 427 | }) | ||
| 428 | } | ||
| 429 | |||
| 430 | /// Repositions the oldest paragraph insertion conflict against reviewed cache images. | ||
| 431 | /// Object identities and dependent edits are retained; uncertain attempts cannot be moved. | ||
| 432 | pub fn rebase_paragraph_conflict( | ||
| 433 | &self, | ||
| 434 | id: u64, | ||
| 435 | local: &[u8], | ||
| 436 | remote: &[u8], | ||
| 437 | parent: ExGuid, | ||
| 438 | before: Option<ExGuid>, | ||
| 439 | ) -> Result<()> { | ||
| 440 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 441 | let Operation::Insert(insertion) = intent.operation else { | ||
| 442 | return Err(io::Error::new( | ||
| 443 | io::ErrorKind::InvalidInput, | ||
| 444 | "Select a paragraph insertion conflict", | ||
| 445 | ) | ||
| 446 | .into()); | ||
| 447 | }; | ||
| 448 | let insertion = insertion.reposition_paragraph(parent, before)?; | ||
| 449 | PreparedEdit::insert(remote, intent.space, &insertion)?; | ||
| 450 | Ok(Operation::Insert(insertion)) | ||
| 451 | }) | ||
| 452 | } | ||
| 453 | |||
| 454 | /// Repositions the oldest outline insertion conflict against reviewed cache images. | ||
| 455 | /// Object identities and dependent edits are retained; uncertain attempts cannot be moved. | ||
| 456 | pub fn rebase_outline_conflict( | ||
| 457 | &self, | ||
| 458 | id: u64, | ||
| 459 | local: &[u8], | ||
| 460 | remote: &[u8], | ||
| 461 | page: ExGuid, | ||
| 462 | x: f32, | ||
| 463 | y: f32, | ||
| 464 | ) -> Result<()> { | ||
| 465 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 466 | let Operation::Insert(insertion) = intent.operation else { | ||
| 467 | return Err(io::Error::new( | ||
| 468 | io::ErrorKind::InvalidInput, | ||
| 469 | "Select an outline insertion conflict", | ||
| 470 | ) | ||
| 471 | .into()); | ||
| 472 | }; | ||
| 473 | let insertion = insertion.reposition_outline(page, x, y)?; | ||
| 474 | PreparedEdit::insert(remote, intent.space, &insertion)?; | ||
| 475 | Ok(Operation::Insert(insertion)) | ||
| 476 | }) | ||
| 477 | } | ||
| 478 | |||
| 479 | /// Reviews the original layout intent against both current cache images. | ||
| 480 | /// Competing property values are replaced only after this explicit review. | ||
| 481 | pub fn rebase_layout_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> { | ||
| 482 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 483 | let Operation::Outline(edit) = intent.operation else { | ||
| 484 | return Err(io::Error::new( | ||
| 485 | io::ErrorKind::InvalidInput, | ||
| 486 | "Select an outline layout conflict", | ||
| 487 | ) | ||
| 488 | .into()); | ||
| 489 | }; | ||
| 490 | Ok(Operation::Outline( | ||
| 491 | OutlineEdit::capture(remote, intent.space, edit.object, edit.change)?.0, | ||
| 492 | )) | ||
| 493 | }) | ||
| 494 | } | ||
| 495 | |||
| 496 | /// Reviews the original subtree intent against both current cache images. | ||
| 497 | /// Replacement paragraph identities must remain unchanged for dependent edits. | ||
| 498 | pub fn rebase_tree_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> { | ||
| 499 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 500 | let Operation::Tree(edit) = intent.operation else { | ||
| 501 | return Err(io::Error::new( | ||
| 502 | io::ErrorKind::InvalidInput, | ||
| 503 | "Select a subtree move or deletion conflict", | ||
| 504 | ) | ||
| 505 | .into()); | ||
| 506 | }; | ||
| 507 | Ok(Operation::Tree(edit.review(remote, intent.space)?)) | ||
| 508 | }) | ||
| 509 | } | ||
| 510 | |||
| 511 | fn resolve_conflict( | ||
| 512 | &self, | ||
| 513 | id: u64, | ||
| 514 | local: &[u8], | ||
| 515 | remote: &[u8], | ||
| 516 | update: impl FnOnce(PendingEdit) -> Result<Operation>, | ||
| 517 | ) -> Result<()> { | ||
| 518 | let owner = self.sync_owner()?; | ||
| 519 | let intent = self | ||
| 520 | .pending()? | ||
| 521 | .into_iter() | ||
| 522 | .next() | ||
| 523 | .filter(|intent| intent.id == id) | ||
| 524 | .ok_or_else(|| { | ||
| 525 | io::Error::new( | ||
| 526 | io::ErrorKind::InvalidInput, | ||
| 527 | "Only the oldest conflict can be rebased", | ||
| 528 | ) | ||
| 529 | })?; | ||
| 530 | let operation = update(intent)?; | ||
| 531 | let mut connection = self | ||
| 532 | .connection | ||
| 533 | .lock() | ||
| 534 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 535 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 536 | let (base, working): (Vec<u8>, Vec<u8>) = | ||
| 537 | transaction.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| { | ||
| 538 | Ok((row.get(0)?, row.get(1)?)) | ||
| 539 | })?; | ||
| 540 | if working != local || base != remote { | ||
| 541 | return Err(io::Error::new( | ||
| 542 | io::ErrorKind::ResourceBusy, | ||
| 543 | "The reviewed cache images changed", | ||
| 544 | ) | ||
| 545 | .into()); | ||
| 546 | } | ||
| 547 | let id = i64::try_from(id).map_err(io::Error::other)?; | ||
| 548 | let eligible: bool = transaction.query_row( | ||
| 549 | "SELECT EXISTS(SELECT 1 FROM conflicts WHERE edit_id=?1) AND NOT EXISTS(SELECT 1 FROM attempt)", | ||
| 550 | [id], |row| row.get(0), | ||
| 551 | )?; | ||
| 552 | if !eligible { | ||
| 553 | return Err(io::Error::new( | ||
| 554 | io::ErrorKind::InvalidInput, | ||
| 555 | "The edit is not an unattempted conflict", | ||
| 556 | ) | ||
| 557 | .into()); | ||
| 558 | } | ||
| 559 | transaction.execute( | ||
| 560 | "UPDATE edits SET operation=?1 WHERE id=?2", | ||
| 561 | params![ | ||
| 562 | serde_json::to_string(&operation).map_err(io::Error::other)?, | ||
| 563 | id | ||
| 564 | ], | ||
| 565 | )?; | ||
| 566 | transaction.execute("DELETE FROM conflicts WHERE edit_id=?1", [id])?; | ||
| 567 | transaction.commit()?; | ||
| 568 | drop(connection); | ||
| 569 | drop(owner); | ||
| 570 | self.wake_sync(); | ||
| 571 | Ok(()) | ||
| 572 | } | ||
| 573 | |||
| 574 | fn sync_owner(&self) -> Result<MutexGuard<'_, ()>> { | ||
| 575 | Ok(self | ||
| 576 | .synchronization | ||
| 577 | .try_lock() | ||
| 578 | .map_err(|error| match error { | ||
| 579 | TryLockError::WouldBlock => io::Error::from(io::ErrorKind::WouldBlock), | ||
| 580 | TryLockError::Poisoned(_) => { | ||
| 581 | io::Error::other("Synchronization owner panicked; reopen the cache") | ||
| 582 | } | ||
| 583 | })?) | ||
| 584 | } | ||
| 585 | |||
| 586 | fn acknowledge(&self, id: u64, revision: ExGuid, snapshot: &[u8]) -> Result<()> { | ||
| 587 | let id = i64::try_from(id).map_err(io::Error::other)?; | ||
| 588 | let mut connection = self | ||
| 589 | .connection | ||
| 590 | .lock() | ||
| 591 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 592 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 593 | transaction.execute( | ||
| 594 | "INSERT INTO receipts(edit_id, revision) VALUES (?1, ?2)", | ||
| 595 | params![id, revision.to_string()], | ||
| 596 | )?; | ||
| 597 | transaction.execute("DELETE FROM edits WHERE id=?1", [id])?; | ||
| 598 | transaction.execute("UPDATE replica SET base=?1, working=CASE WHEN EXISTS(SELECT 1 FROM edits) THEN working ELSE ?1 END WHERE id=1", [snapshot])?; | ||
| 599 | transaction.commit()?; | ||
| 600 | Ok(()) | ||
| 601 | } | ||
| 602 | } | ||
| 603 | |||
| 604 | pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStatus>> { | ||
| 605 | let id = i64::try_from(id).map_err(io::Error::other)?; | ||
| 606 | if let Some(revision) = connection | ||
| 607 | .query_row( | ||
| 608 | "SELECT revision FROM receipts WHERE edit_id=?1", | ||
| 609 | [id], | ||
| 610 | |row| row.get::<_, String>(0), | ||
| 611 | ) | ||
| 612 | .optional()? | ||
| 613 | { | ||
| 614 | return Ok(Some(EditStatus::Published { | ||
| 615 | revision: revision.parse()?, | ||
| 616 | })); | ||
| 617 | } | ||
| 618 | let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; | ||
| 619 | let column = if version < 10 { | ||
| 620 | "revision" | ||
| 621 | } else { | ||
| 622 | "revisions" | ||
| 623 | }; | ||
| 624 | let record: Option<(Option<String>, Option<i64>, String)> = connection.query_row( | ||
| 625 | &format!("SELECT attempt.{column}, conflicts.kind, edits.space FROM edits LEFT JOIN attempt ON attempt.edit_id=edits.id LEFT JOIN conflicts ON conflicts.edit_id=edits.id WHERE edits.id=?1"), [id], |row| Ok((row.get(0)?,row.get(1)?, row.get(2)?))).optional()?; | ||
| 626 | Ok(match record { | ||
| 627 | None => None, | ||
| 628 | Some((Some(revision), _, space)) => Some(EditStatus::AwaitingConfirmation { | ||
| 629 | revision: if version < 10 { | ||
| 630 | revision.parse()? | ||
| 631 | } else { | ||
| 632 | let space = space.parse()?; | ||
| 633 | attempted_revisions(&revision, space)?[&space] | ||
| 634 | }, | ||
| 635 | }), | ||
| 636 | Some((None, Some(kind), _)) => Some(EditStatus::Conflict(match kind { | ||
| 637 | 0 => ConflictKind::TextChanged, | ||
| 638 | 1 => ConflictKind::TargetUnavailable, | ||
| 639 | 2 => ConflictKind::UnsupportedEdit, | ||
| 640 | 3 => ConflictKind::FormattingChanged, | ||
| 641 | 4 => ConflictKind::StructureChanged, | ||
| 642 | 5 => ConflictKind::LayoutChanged, | ||
| 643 | 6 => ConflictKind::ContentChanged, | ||
| 644 | _ => { | ||
| 645 | return Err(io::Error::new( | ||
| 646 | io::ErrorKind::InvalidData, | ||
| 647 | "Unknown cached conflict kind", | ||
| 648 | ) | ||
| 649 | .into()); | ||
| 650 | } | ||
| 651 | })), | ||
| 652 | Some((None, None, _)) => Some(EditStatus::Pending), | ||
| 653 | }) | ||
| 654 | } | ||
| 655 | |||
| 656 | fn attempted_revisions(encoded: &str, space: ExGuid) -> Result<BTreeMap<ExGuid, ExGuid>> { | ||
| 657 | let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(encoded) | ||
| 658 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; | ||
| 659 | if !revisions.contains_key(&space) | ||
| 660 | || revisions | ||
| 661 | .iter() | ||
| 662 | .any(|(sid, rid)| sid.guid == [0; 16] || rid.guid == [0; 16]) | ||
| 663 | { | ||
| 664 | return Err(io::Error::new( | ||
| 665 | io::ErrorKind::InvalidData, | ||
| 666 | "Cached publication evidence is incomplete", | ||
| 667 | ) | ||
| 668 | .into()); | ||
| 669 | } | ||
| 670 | Ok(revisions) | ||
| 671 | } | ||
crates/notebook/src/tree.rs created+541| ... | @@ -0,0 +1,541 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{FileDataReference, IdStream, ObjectData, PropertySets, ResolvedRevision, Value}; | ||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | use sha2::{Digest, Sha256}; | ||
| 5 | use std::collections::{BTreeMap, BTreeSet}; | ||
| 6 | |||
| 7 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 8 | #[serde(untagged, deny_unknown_fields)] | ||
| 9 | enum Content { | ||
| 10 | Legacy([u8; 32]), | ||
| 11 | Semantic { sha256: [u8; 32] }, | ||
| 12 | } | ||
| 13 | |||
| 14 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 15 | #[serde(deny_unknown_fields)] | ||
| 16 | struct Observed { | ||
| 17 | path: Vec<(ExGuid, u8)>, | ||
| 18 | siblings: BTreeMap<ExGuid, bool>, | ||
| 19 | destination: Vec<(ExGuid, u8)>, | ||
| 20 | content: Option<Content>, | ||
| 21 | } | ||
| 22 | |||
| 23 | /// A subtree intent with observed placement, deletion content and replacement identities. | ||
| 24 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 25 | #[serde(deny_unknown_fields)] | ||
| 26 | pub struct TreeEdit { | ||
| 27 | pub intent: onestore::TreeEdit, | ||
| 28 | observed: Observed, | ||
| 29 | created: BTreeSet<ExGuid>, | ||
| 30 | } | ||
| 31 | |||
| 32 | fn fingerprint( | ||
| 33 | store: &Store<'_>, | ||
| 34 | raw: &ResolvedRevision<'_>, | ||
| 35 | root: ExGuid, | ||
| 36 | legacy: bool, | ||
| 37 | ) -> Result<[u8; 32]> { | ||
| 38 | let mut objects = BTreeMap::new(); | ||
| 39 | let mut visiting = BTreeSet::new(); | ||
| 40 | let mut pending = vec![(root, false)]; | ||
| 41 | while let Some((id, complete)) = pending.pop() { | ||
| 42 | if objects.contains_key(&id) { | ||
| 43 | continue; | ||
| 44 | } | ||
| 45 | let object = &raw.objects[&id]; | ||
| 46 | let references = object.references()?; | ||
| 47 | if !complete { | ||
| 48 | if !visiting.insert(id) { | ||
| 49 | return Err(io::Error::new( | ||
| 50 | io::ErrorKind::InvalidData, | ||
| 51 | "Subtree property references form a cycle", | ||
| 52 | ) | ||
| 53 | .into()); | ||
| 54 | } | ||
| 55 | pending.push((id, true)); | ||
| 56 | pending.extend(references.objects.iter().map(|id| (*id, false))); | ||
| 57 | continue; | ||
| 58 | } | ||
| 59 | visiting.remove(&id); | ||
| 60 | let mut hash = Sha256::new(); | ||
| 61 | hash.update(object.jcid.to_le_bytes()); | ||
| 62 | match object.data { | ||
| 63 | ObjectData::Properties(bytes) => { | ||
| 64 | hash.update([0]); | ||
| 65 | let properties = PropertySets::parse(bytes)?; | ||
| 66 | let mut object_ids = references.objects.into_iter(); | ||
| 67 | let mut spaces = references.object_spaces.into_iter(); | ||
| 68 | let mut contexts = references.contexts.into_iter(); | ||
| 69 | let mut fields = Vec::new(); | ||
| 70 | for set in &properties.sets { | ||
| 71 | let mut values = BTreeMap::new(); | ||
| 72 | for property in set { | ||
| 73 | let mut value = Sha256::new(); | ||
| 74 | match &property.value { | ||
| 75 | Value::NoData => {} | ||
| 76 | Value::Bytes(bytes) => value.update(bytes), | ||
| 77 | Value::References { | ||
| 78 | stream, | ||
| 79 | compact_ids, | ||
| 80 | } => { | ||
| 81 | let references = match stream { | ||
| 82 | IdStream::Objects => &mut object_ids, | ||
| 83 | IdStream::ObjectSpaces => &mut spaces, | ||
| 84 | IdStream::Contexts => &mut contexts, | ||
| 85 | }; | ||
| 86 | for reference in references.take(compact_ids.len() / 4) { | ||
| 87 | if !legacy && *stream == IdStream::Objects { | ||
| 88 | let immutable = | ||
| 89 | raw.objects[&reference].jcid & 0x100000 != 0; | ||
| 90 | value.update([u8::from(immutable)]); | ||
| 91 | if !immutable { | ||
| 92 | value.update(reference.guid); | ||
| 93 | value.update(reference.n.to_le_bytes()); | ||
| 94 | } | ||
| 95 | value.update(objects[&reference]); | ||
| 96 | } else { | ||
| 97 | value.update(reference.guid); | ||
| 98 | value.update(reference.n.to_le_bytes()); | ||
| 99 | } | ||
| 100 | } | ||
| 101 | } | ||
| 102 | Value::Sets(_) => {} | ||
| 103 | } | ||
| 104 | if property.id == 0x14001d7a | ||
| 105 | || (!legacy | ||
| 106 | && property.id == 0x24001c20 | ||
| 107 | && matches!(&property.value, Value::References { compact_ids, .. } if compact_ids.is_empty())) | ||
| 108 | { | ||
| 109 | continue; | ||
| 110 | } | ||
| 111 | values.insert(property.id, value); | ||
| 112 | } | ||
| 113 | fields.push(values); | ||
| 114 | } | ||
| 115 | // Arena indices and CompactIDs can change without changing property content. | ||
| 116 | let mut sets = vec![[0; 32]; properties.sets.len()]; | ||
| 117 | for at in (0..properties.sets.len()).rev() { | ||
| 118 | for property in &properties.sets[at] { | ||
| 119 | if let Value::Sets(children) = &property.value { | ||
| 120 | let value = fields[at].get_mut(&property.id).unwrap(); | ||
| 121 | for child in children.clone() { | ||
| 122 | value.update(sets[child]); | ||
| 123 | } | ||
| 124 | } | ||
| 125 | } | ||
| 126 | let mut set = Sha256::new(); | ||
| 127 | for (id, value) in &fields[at] { | ||
| 128 | set.update(id.to_le_bytes()); | ||
| 129 | set.update(value.clone().finalize()); | ||
| 130 | } | ||
| 131 | sets[at] = set.finalize().into(); | ||
| 132 | } | ||
| 133 | hash.update(sets[0]); | ||
| 134 | } | ||
| 135 | ObjectData::File { | ||
| 136 | reference, | ||
| 137 | extension, | ||
| 138 | } => { | ||
| 139 | hash.update([1]); | ||
| 140 | hash.update(Sha256::digest(reference)); | ||
| 141 | hash.update(Sha256::digest(extension)); | ||
| 142 | if let Some(FileDataReference::Internal(guid)) = object.file_reference()? { | ||
| 143 | hash.update(Sha256::digest(store.file_data(guid)?)); | ||
| 144 | } | ||
| 145 | } | ||
| 146 | ObjectData::Encrypted(_) => unreachable!("references rejected encrypted content"), | ||
| 147 | } | ||
| 148 | objects.insert(id, <[u8; 32]>::from(hash.finalize())); | ||
| 149 | } | ||
| 150 | if !legacy { | ||
| 151 | return Ok(objects[&root]); | ||
| 152 | } | ||
| 153 | let mut hash = Sha256::new(); | ||
| 154 | for (id, value) in objects { | ||
| 155 | hash.update(id.guid); | ||
| 156 | hash.update(id.n.to_le_bytes()); | ||
| 157 | hash.update(value); | ||
| 158 | } | ||
| 159 | Ok(hash.finalize().into()) | ||
| 160 | } | ||
| 161 | |||
| 162 | fn observe( | ||
| 163 | source: &[u8], | ||
| 164 | space: ExGuid, | ||
| 165 | intent: &onestore::TreeEdit, | ||
| 166 | legacy: bool, | ||
| 167 | ) -> Result<Option<Observed>> { | ||
| 168 | let store = Store::parse(source)?; | ||
| 169 | let index = RevisionIndex::parse(&store)?; | ||
| 170 | let document = Document::parse(&index)?; | ||
| 171 | let Ok(view) = document.active(space) else { | ||
| 172 | return Ok(None); | ||
| 173 | }; | ||
| 174 | let pages = document.pages_in(space)?; | ||
| 175 | if pages.len() != 1 { | ||
| 176 | return Ok(None); | ||
| 177 | } | ||
| 178 | let object = intent.object(); | ||
| 179 | let mut targets = vec![object]; | ||
| 180 | if let Some((parent, _)) = intent.destination() { | ||
| 181 | targets.push(parent); | ||
| 182 | } | ||
| 183 | let Some(paths) = active_paths(view, &pages, &targets) else { | ||
| 184 | return Ok(None); | ||
| 185 | }; | ||
| 186 | let Some(parent) = paths[0].first() else { | ||
| 187 | return Ok(None); | ||
| 188 | }; | ||
| 189 | let children = &view.nodes[parent].children; | ||
| 190 | let Some(position) = children.iter().position(|id| *id == object) else { | ||
| 191 | return Ok(None); | ||
| 192 | }; | ||
| 193 | let levels = |path: &[ExGuid]| { | ||
| 194 | path.iter() | ||
| 195 | .map(|id| (*id, view.nodes[id].child_level.unwrap_or(1))) | ||
| 196 | .collect::<Vec<_>>() | ||
| 197 | }; | ||
| 198 | let destination = if let Some((parent, _)) = intent.destination() { | ||
| 199 | levels(&[&[parent], paths[1].as_slice()].concat()) | ||
| 200 | } else { | ||
| 201 | Vec::new() | ||
| 202 | }; | ||
| 203 | Ok(Some(Observed { | ||
| 204 | path: levels(&paths[0]), | ||
| 205 | siblings: children | ||
| 206 | .iter() | ||
| 207 | .enumerate() | ||
| 208 | .filter_map(|(at, id)| (*id != object).then_some((*id, at < position))) | ||
| 209 | .collect(), | ||
| 210 | destination, | ||
| 211 | content: if intent.destination().is_none() { | ||
| 212 | let sha256 = fingerprint(&store, &index.resolve_active(space)?, object, legacy)?; | ||
| 213 | Some(if legacy { | ||
| 214 | Content::Legacy(sha256) | ||
| 215 | } else { | ||
| 216 | Content::Semantic { sha256 } | ||
| 217 | }) | ||
| 218 | } else { | ||
| 219 | None | ||
| 220 | }, | ||
| 221 | })) | ||
| 222 | } | ||
| 223 | |||
| 224 | fn mutable_objects(source: &[u8], space: ExGuid) -> Result<BTreeSet<ExGuid>> { | ||
| 225 | let store = Store::parse(source)?; | ||
| 226 | let index = RevisionIndex::parse(&store)?; | ||
| 227 | let raw = index.resolve_active(space)?; | ||
| 228 | Ok(raw | ||
| 229 | .reachable()? | ||
| 230 | .into_iter() | ||
| 231 | .filter(|id| raw.objects[id].jcid & 0x100000 == 0) | ||
| 232 | .collect()) | ||
| 233 | } | ||
| 234 | |||
| 235 | impl Replica { | ||
| 236 | /// Queues a move or deletion, retaining content changes for explicit deletion review. | ||
| 237 | pub fn tree( | ||
| 238 | &self, | ||
| 239 | source: &[u8], | ||
| 240 | space: ExGuid, | ||
| 241 | intent: &onestore::TreeEdit, | ||
| 242 | ) -> Result<Option<u64>> { | ||
| 243 | let (edit, prepared) = TreeEdit::capture(source, space, intent)?; | ||
| 244 | self.record(source, space, Operation::Tree(edit), &prepared) | ||
| 245 | } | ||
| 246 | } | ||
| 247 | |||
| 248 | impl TreeEdit { | ||
| 249 | pub(crate) fn capture<'a>( | ||
| 250 | source: &'a [u8], | ||
| 251 | space: ExGuid, | ||
| 252 | intent: &onestore::TreeEdit, | ||
| 253 | ) -> Result<(Self, PreparedEdit<'a>)> { | ||
| 254 | let prepared = PreparedEdit::tree(source, space, intent)?; | ||
| 255 | let observed = observe(source, space, intent, false)?.ok_or_else(|| { | ||
| 256 | io::Error::new( | ||
| 257 | io::ErrorKind::InvalidData, | ||
| 258 | "Prepared subtree edit has no active target", | ||
| 259 | ) | ||
| 260 | })?; | ||
| 261 | let before = mutable_objects(source, space)?; | ||
| 262 | let after = mutable_objects(prepared.as_bytes(), space)?; | ||
| 263 | Ok(( | ||
| 264 | Self { | ||
| 265 | intent: intent.clone(), | ||
| 266 | observed, | ||
| 267 | created: &after - &before, | ||
| 268 | }, | ||
| 269 | prepared, | ||
| 270 | )) | ||
| 271 | } | ||
| 272 | |||
| 273 | pub(crate) fn prepare<'a>( | ||
| 274 | &self, | ||
| 275 | source: &'a [u8], | ||
| 276 | space: ExGuid, | ||
| 277 | ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> { | ||
| 278 | let Some(current) = observe( | ||
| 279 | source, | ||
| 280 | space, | ||
| 281 | &self.intent, | ||
| 282 | matches!(self.observed.content, Some(Content::Legacy(_))), | ||
| 283 | )? | ||
| 284 | else { | ||
| 285 | return Ok(Err(ConflictKind::TargetUnavailable)); | ||
| 286 | }; | ||
| 287 | let prepared = match PreparedEdit::tree(source, space, &self.intent) { | ||
| 288 | Ok(prepared) => prepared, | ||
| 289 | Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)), | ||
| 290 | }; | ||
| 291 | let after = mutable_objects(prepared.as_bytes(), space)?; | ||
| 292 | if prepared.as_bytes() == source { | ||
| 293 | return Ok(if self.created.is_subset(&after) { | ||
| 294 | Ok(prepared) | ||
| 295 | } else { | ||
| 296 | Err(ConflictKind::StructureChanged) | ||
| 297 | }); | ||
| 298 | } | ||
| 299 | if current.path != self.observed.path | ||
| 300 | || current.destination != self.observed.destination | ||
| 301 | || current.siblings.iter().any(|(id, before)| { | ||
| 302 | self.observed | ||
| 303 | .siblings | ||
| 304 | .get(id) | ||
| 305 | .is_some_and(|was_before| before != was_before) | ||
| 306 | }) | ||
| 307 | || &after - &mutable_objects(source, space)? != self.created | ||
| 308 | { | ||
| 309 | return Ok(Err(ConflictKind::StructureChanged)); | ||
| 310 | } | ||
| 311 | if current.content != self.observed.content { | ||
| 312 | return Ok(Err(ConflictKind::ContentChanged)); | ||
| 313 | } | ||
| 314 | Ok(Ok(prepared)) | ||
| 315 | } | ||
| 316 | |||
| 317 | pub(crate) fn review(&self, source: &[u8], space: ExGuid) -> Result<Self> { | ||
| 318 | let (reviewed, _) = Self::capture(source, space, &self.intent)?; | ||
| 319 | if reviewed.created != self.created { | ||
| 320 | return Err(io::Error::new( | ||
| 321 | io::ErrorKind::InvalidInput, | ||
| 322 | "The subtree edit would change its replacement paragraph identities", | ||
| 323 | ) | ||
| 324 | .into()); | ||
| 325 | } | ||
| 326 | Ok(reviewed) | ||
| 327 | } | ||
| 328 | } | ||
| 329 | |||
| 330 | #[cfg(test)] | ||
| 331 | mod tests { | ||
| 332 | use super::*; | ||
| 333 | use onestore::Object; | ||
| 334 | use std::sync::Arc; | ||
| 335 | |||
| 336 | fn set(fields: &[(u32, Vec<u8>)]) -> Vec<u8> { | ||
| 337 | let mut bytes = u16::try_from(fields.len()).unwrap().to_le_bytes().to_vec(); | ||
| 338 | for (id, _) in fields { | ||
| 339 | bytes.extend(id.to_le_bytes()); | ||
| 340 | } | ||
| 341 | for (_, value) in fields { | ||
| 342 | bytes.extend(value); | ||
| 343 | } | ||
| 344 | bytes | ||
| 345 | } | ||
| 346 | |||
| 347 | #[test] | ||
| 348 | fn deletion_fingerprint_resolves_references_and_nested_sets_without_storage_order() { | ||
| 349 | let source = onestore::create_section("fingerprint.one", "Original", "Author").unwrap(); | ||
| 350 | let store = Store::parse(&source).unwrap(); | ||
| 351 | let root = ExGuid { | ||
| 352 | guid: [1; 16], | ||
| 353 | n: 1, | ||
| 354 | }; | ||
| 355 | let child = ExGuid { | ||
| 356 | guid: [2; 16], | ||
| 357 | n: 7, | ||
| 358 | }; | ||
| 359 | let hash = |reverse: bool, global: u32, timestamp: u32, change: usize, immutable: bool| { | ||
| 360 | let mut fields = vec![ | ||
| 361 | (0x14001d7a, timestamp.to_le_bytes().to_vec()), | ||
| 362 | (0x20000001, vec![]), | ||
| 363 | ( | ||
| 364 | 0x44000002, | ||
| 365 | set(&[(0x14000003, (u32::from(change == 1)).to_le_bytes().to_vec())]), | ||
| 366 | ), | ||
| 367 | ( | ||
| 368 | 0x44000004, | ||
| 369 | set(&[(if change == 2 { 0x08000005 } else { 0x88000005 }, vec![])]), | ||
| 370 | ), | ||
| 371 | ]; | ||
| 372 | if change != 5 { | ||
| 373 | fields.push((0x24001c20, 0_u32.to_le_bytes().to_vec())); | ||
| 374 | } | ||
| 375 | if change == 6 { | ||
| 376 | fields.push((0x24000007, 0_u32.to_le_bytes().to_vec())); | ||
| 377 | } | ||
| 378 | if reverse { | ||
| 379 | fields.reverse(); | ||
| 380 | } | ||
| 381 | let mut bytes = 0x80000001_u32.to_le_bytes().to_vec(); | ||
| 382 | bytes.extend(((global << 8) | child.n).to_le_bytes()); | ||
| 383 | bytes.extend(set(&fields)); | ||
| 384 | let mut child_bytes = 0x80000000_u32.to_le_bytes().to_vec(); | ||
| 385 | child_bytes.extend(set(&[( | ||
| 386 | 0x14000006, | ||
| 387 | (u32::from(change == 3)).to_le_bytes().to_vec(), | ||
| 388 | )])); | ||
| 389 | let ids = Arc::new(BTreeMap::from([( | ||
| 390 | global, | ||
| 391 | if change == 4 { [3; 16] } else { child.guid }, | ||
| 392 | )])); | ||
| 393 | let target = ExGuid { | ||
| 394 | guid: ids[&global], | ||
| 395 | ..child | ||
| 396 | }; | ||
| 397 | let raw = ResolvedRevision { | ||
| 398 | roots: BTreeMap::from([(1, root)]), | ||
| 399 | objects: BTreeMap::from([ | ||
| 400 | ( | ||
| 401 | root, | ||
| 402 | Object { | ||
| 403 | jcid: 0x6000d, | ||
| 404 | reference_count: 1, | ||
| 405 | data: ObjectData::Properties(&bytes), | ||
| 406 | global_ids: ids.clone(), | ||
| 407 | }, | ||
| 408 | ), | ||
| 409 | ( | ||
| 410 | target, | ||
| 411 | Object { | ||
| 412 | jcid: if immutable { 0x12004d } else { 0x6000e }, | ||
| 413 | reference_count: 1, | ||
| 414 | data: ObjectData::Properties(&child_bytes), | ||
| 415 | global_ids: ids, | ||
| 416 | }, | ||
| 417 | ), | ||
| 418 | ]), | ||
| 419 | }; | ||
| 420 | fingerprint(&store, &raw, root, false).unwrap() | ||
| 421 | }; | ||
| 422 | let original = hash(false, 0, 1, 0, false); | ||
| 423 | assert_eq!(original, hash(true, 137, 2, 0, false)); | ||
| 424 | assert_eq!(original, hash(true, 137, 2, 5, false)); | ||
| 425 | assert_ne!(original, hash(true, 137, 2, 6, false)); | ||
| 426 | for change in 1..=4 { | ||
| 427 | assert_ne!(original, hash(true, 137, 2, change, false)); | ||
| 428 | } | ||
| 429 | let immutable = hash(false, 0, 1, 0, true); | ||
| 430 | assert_eq!(immutable, hash(true, 137, 2, 4, true)); | ||
| 431 | for change in 1..=3 { | ||
| 432 | assert_ne!(immutable, hash(true, 137, 2, change, true)); | ||
| 433 | } | ||
| 434 | } | ||
| 435 | |||
| 436 | #[test] | ||
| 437 | fn version_eight_deletion_observations_survive_migration_and_upgrade_only_after_review() { | ||
| 438 | struct Server(Vec<u8>); | ||
| 439 | impl Remote for Server { | ||
| 440 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 441 | Ok(self.0.clone()) | ||
| 442 | } | ||
| 443 | fn publish( | ||
| 444 | &mut self, | ||
| 445 | edit: &PreparedEdit<'_>, | ||
| 446 | ) -> std::result::Result<(), onestore::CommitError> { | ||
| 447 | self.0 = edit.as_bytes().to_vec(); | ||
| 448 | Ok(()) | ||
| 449 | } | ||
| 450 | fn confirm( | ||
| 451 | &mut self, | ||
| 452 | snapshot: &[u8], | ||
| 453 | ) -> std::result::Result<(), onestore::CommitError> { | ||
| 454 | assert!(self.0 == snapshot); | ||
| 455 | Ok(()) | ||
| 456 | } | ||
| 457 | } | ||
| 458 | let source = onestore::create_section("legacy.one", "AlphaOmega", "Author").unwrap(); | ||
| 459 | let store = Store::parse(&source).unwrap(); | ||
| 460 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 461 | let document = Document::parse(&index).unwrap(); | ||
| 462 | let (sid, _) = document.pages().unwrap()[0]; | ||
| 463 | let view = document.active(sid).unwrap(); | ||
| 464 | let text = *view.nodes.iter().find(|(_, node)| matches!(&node.kind, Kind::RichText { text, .. } if text == "AlphaOmega")).unwrap().0; | ||
| 465 | let directory = tempfile::tempdir().unwrap(); | ||
| 466 | let path = directory.path().join("legacy.sqlite"); | ||
| 467 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 468 | cache | ||
| 469 | .format( | ||
| 470 | &source, | ||
| 471 | sid, | ||
| 472 | text, | ||
| 473 | 0..10, | ||
| 474 | &[onestore::TextAttribute::Bold(true)], | ||
| 475 | ) | ||
| 476 | .unwrap(); | ||
| 477 | let split = onestore::ParagraphSplit::new(text, 5, "Author").unwrap(); | ||
| 478 | cache | ||
| 479 | .split(&cache.snapshot().unwrap(), sid, &split) | ||
| 480 | .unwrap(); | ||
| 481 | let before = cache.snapshot().unwrap(); | ||
| 482 | let intent = onestore::TreeEdit::delete(split.object(), "Author").unwrap(); | ||
| 483 | let deletion = cache.tree(&before, sid, &intent).unwrap().unwrap(); | ||
| 484 | let dependent = cache | ||
| 485 | .edit_text(&cache.snapshot().unwrap(), sid, text, 0..0, "Local ") | ||
| 486 | .unwrap() | ||
| 487 | .unwrap(); | ||
| 488 | let mut queue = cache.pending().unwrap(); | ||
| 489 | let Operation::Tree(edit) = &mut queue[2].operation else { | ||
| 490 | panic!() | ||
| 491 | }; | ||
| 492 | edit.observed = observe(&before, sid, &intent, true).unwrap().unwrap(); | ||
| 493 | let serialized = serde_json::to_string(&queue[2].operation).unwrap(); | ||
| 494 | let local = cache.snapshot().unwrap(); | ||
| 495 | drop(cache); | ||
| 496 | let db = Connection::open(&path).unwrap(); | ||
| 497 | db.execute( | ||
| 498 | "UPDATE edits SET operation=?1 WHERE id=?2", | ||
| 499 | params![serialized, i64::try_from(deletion).unwrap()], | ||
| 500 | ) | ||
| 501 | .unwrap(); | ||
| 502 | db.pragma_update(None, "user_version", 8).unwrap(); | ||
| 503 | db.execute_batch("ALTER TABLE attempt RENAME COLUMN revisions TO revision;") | ||
| 504 | .unwrap(); | ||
| 505 | drop(db); | ||
| 506 | let cache = Replica::open(&path).unwrap(); | ||
| 507 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 508 | assert!(cache.snapshot().unwrap() == local); | ||
| 509 | let mut server = Server(source); | ||
| 510 | for _ in 0..2 { | ||
| 511 | assert!(matches!( | ||
| 512 | cache.sync_once(&mut server).unwrap(), | ||
| 513 | Some((_, EditStatus::Published { .. })) | ||
| 514 | )); | ||
| 515 | } | ||
| 516 | assert_eq!( | ||
| 517 | cache.sync_once(&mut server).unwrap(), | ||
| 518 | Some((deletion, EditStatus::Conflict(ConflictKind::ContentChanged))) | ||
| 519 | ); | ||
| 520 | cache | ||
| 521 | .rebase_tree_conflict(deletion, &local, &server.0) | ||
| 522 | .unwrap(); | ||
| 523 | let queue = cache.pending().unwrap(); | ||
| 524 | let Operation::Tree(edit) = &queue[0].operation else { | ||
| 525 | panic!() | ||
| 526 | }; | ||
| 527 | assert!(matches!( | ||
| 528 | edit.observed.content, | ||
| 529 | Some(Content::Semantic { .. }) | ||
| 530 | )); | ||
| 531 | for id in [deletion, dependent] { | ||
| 532 | assert!( | ||
| 533 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | ||
| 534 | ); | ||
| 535 | } | ||
| 536 | assert_eq!( | ||
| 537 | paragraph(&server.0, sid, text).unwrap().as_deref(), | ||
| 538 | Some("Local Alpha") | ||
| 539 | ); | ||
| 540 | } | ||
| 541 | } | ||
crates/notebook/src/worker.rs created+167| ... | @@ -0,0 +1,167 @@ | ||
| 1 | use super::*; | ||
| 2 | use std::{ | ||
| 3 | collections::hash_map::RandomState, | ||
| 4 | hash::BuildHasher, | ||
| 5 | sync::{ | ||
| 6 | Arc, | ||
| 7 | atomic::{AtomicBool, Ordering}, | ||
| 8 | mpsc::{self, SyncSender}, | ||
| 9 | }, | ||
| 10 | thread::{self, JoinHandle}, | ||
| 11 | time::Instant, | ||
| 12 | }; | ||
| 13 | |||
| 14 | pub(super) struct Signal { | ||
| 15 | stopped: AtomicBool, | ||
| 16 | sender: SyncSender<()>, | ||
| 17 | } | ||
| 18 | |||
| 19 | impl Signal { | ||
| 20 | pub(super) fn wake(&self) { | ||
| 21 | // One retained notification covers edits that arrive during network I/O. | ||
| 22 | let _ = self.sender.try_send(()); | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | /// Owns automatic reconciliation. Dropping requests cancellation without blocking. | ||
| 27 | /// The in-flight sync step finishes before ownership is released; `stop` waits for it. | ||
| 28 | pub struct SyncWorker { | ||
| 29 | signal: Arc<Signal>, | ||
| 30 | thread: Option<JoinHandle<Result<()>>>, | ||
| 31 | } | ||
| 32 | |||
| 33 | impl SyncWorker { | ||
| 34 | /// Requests a retry, for example after a network reachability change. | ||
| 35 | /// A pending contention backoff finishes before processing the notification. | ||
| 36 | pub fn wake(&self) { | ||
| 37 | self.signal.wake(); | ||
| 38 | } | ||
| 39 | |||
| 40 | /// Cancels future steps and waits for the current step and callback to finish. | ||
| 41 | /// A stopped worker leaves pending edits and uncertain attempts in the cache. | ||
| 42 | /// Call outside the worker's own callback, which cannot join its calling thread. | ||
| 43 | pub fn stop(mut self) -> Result<()> { | ||
| 44 | self.signal.stopped.store(true, Ordering::Release); | ||
| 45 | self.signal.wake(); | ||
| 46 | self.thread | ||
| 47 | .take() | ||
| 48 | .expect("Worker owns its thread") | ||
| 49 | .join() | ||
| 50 | .map_err(|_| io::Error::other("Synchronization worker panicked"))? | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | impl Drop for SyncWorker { | ||
| 55 | fn drop(&mut self) { | ||
| 56 | self.signal.stopped.store(true, Ordering::Release); | ||
| 57 | self.signal.wake(); | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | impl Replica { | ||
| 62 | /// Starts one worker, reconnecting through `connect` after transport failures. | ||
| 63 | /// Local edits wake it; `interval` controls idle polling and transport retries. | ||
| 64 | /// Contended operations returning `NotCommitted` also back off by up to one second. | ||
| 65 | /// `observe` runs on the worker after each attempt, including connection errors. | ||
| 66 | /// Cache/document errors stop the worker; inspect them through `observe` or `stop`. | ||
| 67 | /// Remote calls and callbacks must be bounded for `stop` to have bounded latency. | ||
| 68 | pub fn start_sync<R, F, O>( | ||
| 69 | self: &Arc<Self>, | ||
| 70 | interval: Duration, | ||
| 71 | mut connect: F, | ||
| 72 | mut observe: O, | ||
| 73 | ) -> io::Result<SyncWorker> | ||
| 74 | where | ||
| 75 | R: Remote + 'static, | ||
| 76 | F: FnMut() -> io::Result<R> + Send + 'static, | ||
| 77 | O: FnMut(&Result<Option<(u64, EditStatus)>>) + Send + 'static, | ||
| 78 | { | ||
| 79 | if interval.is_zero() || Instant::now().checked_add(interval).is_none() { | ||
| 80 | return Err(io::Error::new( | ||
| 81 | io::ErrorKind::InvalidInput, | ||
| 82 | "Synchronization interval must be positive and representable", | ||
| 83 | )); | ||
| 84 | } | ||
| 85 | let mut owner = self | ||
| 86 | .worker | ||
| 87 | .lock() | ||
| 88 | .map_err(|_| io::Error::other("Synchronization worker registration panicked"))?; | ||
| 89 | if owner.upgrade().is_some() { | ||
| 90 | return Err(io::ErrorKind::WouldBlock.into()); | ||
| 91 | } | ||
| 92 | let (sender, receiver) = mpsc::sync_channel(1); | ||
| 93 | let signal = Arc::new(Signal { | ||
| 94 | stopped: AtomicBool::new(false), | ||
| 95 | sender, | ||
| 96 | }); | ||
| 97 | let replica = Arc::clone(self); | ||
| 98 | let worker_signal = Arc::clone(&signal); | ||
| 99 | let thread = thread::Builder::new() | ||
| 100 | .name("onestore-sync".into()) | ||
| 101 | .spawn(move || { | ||
| 102 | let mut remote = None; | ||
| 103 | let jitter = RandomState::new(); | ||
| 104 | let mut contention = 0_u32; | ||
| 105 | while !worker_signal.stopped.load(Ordering::Acquire) { | ||
| 106 | let result = match remote.as_mut() { | ||
| 107 | Some(remote) => replica.sync_once(remote), | ||
| 108 | None => match connect() { | ||
| 109 | Ok(connected) => { | ||
| 110 | remote = Some(connected); | ||
| 111 | continue; | ||
| 112 | } | ||
| 113 | Err(error) => Err(Error::RemoteIo(error)), | ||
| 114 | }, | ||
| 115 | }; | ||
| 116 | observe(&result); | ||
| 117 | match result { | ||
| 118 | Ok(Some((_, EditStatus::Published { .. }))) => { | ||
| 119 | contention = 0; | ||
| 120 | continue; | ||
| 121 | } | ||
| 122 | Ok(None) => contention = 0, | ||
| 123 | Ok(_) => {} | ||
| 124 | Err(Error::Remote(onestore::CommitError { | ||
| 125 | state: onestore::CommitState::NotCommitted, | ||
| 126 | ref error, | ||
| 127 | })) if matches!( | ||
| 128 | error.kind(), | ||
| 129 | io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy | ||
| 130 | ) => | ||
| 131 | { | ||
| 132 | contention = contention.saturating_add(1); | ||
| 133 | let ceiling = (50_u64 << contention.min(5)).min(1000); | ||
| 134 | let until = Instant::now() | ||
| 135 | + Duration::from_millis(jitter.hash_one(contention) % ceiling); | ||
| 136 | // Local wakes must not keep competing writers in the same retry phase. | ||
| 137 | while !worker_signal.stopped.load(Ordering::Acquire) { | ||
| 138 | let Some(remaining) = until.checked_duration_since(Instant::now()) | ||
| 139 | else { | ||
| 140 | break; | ||
| 141 | }; | ||
| 142 | let _ = receiver.recv_timeout(remaining); | ||
| 143 | } | ||
| 144 | continue; | ||
| 145 | } | ||
| 146 | Err(Error::RemoteIo(ref error)) | ||
| 147 | if matches!( | ||
| 148 | error.kind(), | ||
| 149 | io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy | ||
| 150 | ) => {} | ||
| 151 | Err(Error::RemoteIo(_) | Error::Remote(_)) => remote = None, | ||
| 152 | Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {} | ||
| 153 | Err(error) => return Err(error), | ||
| 154 | } | ||
| 155 | if !worker_signal.stopped.load(Ordering::Acquire) { | ||
| 156 | let _ = receiver.recv_timeout(interval); | ||
| 157 | } | ||
| 158 | } | ||
| 159 | Ok(()) | ||
| 160 | })?; | ||
| 161 | *owner = Arc::downgrade(&signal); | ||
| 162 | Ok(SyncWorker { | ||
| 163 | signal, | ||
| 164 | thread: Some(thread), | ||
| 165 | }) | ||
| 166 | } | ||
| 167 | } | ||
crates/notebook/tests/assets.rs created+449| ... | @@ -0,0 +1,449 @@ | ||
| 1 | use notebook::{EditStatus, Error, Operation, Recovery, Replica}; | ||
| 2 | use std::{ | ||
| 3 | fs, io, | ||
| 4 | path::Path, | ||
| 5 | sync::{Barrier, mpsc}, | ||
| 6 | time::Duration, | ||
| 7 | }; | ||
| 8 | |||
| 9 | const FIXTURE: &str = "../../corpus/native-external-assets/notebook"; | ||
| 10 | const LEGACY: &str = "../../corpus/offline-v4/live.sqlite"; | ||
| 11 | |||
| 12 | fn copied_cache(root: &Path) -> Replica { | ||
| 13 | let path = root.join("cache.sqlite"); | ||
| 14 | assert!(!path.exists()); | ||
| 15 | fs::copy(LEGACY, &path).unwrap(); | ||
| 16 | Replica::open(path).unwrap() | ||
| 17 | } | ||
| 18 | |||
| 19 | fn payload(size: usize) -> (String, Vec<u8>) { | ||
| 20 | fs::read_dir(Path::new(FIXTURE).join("synthetic_onefiles")) | ||
| 21 | .unwrap() | ||
| 22 | .map(|entry| entry.unwrap().path()) | ||
| 23 | .find_map(|path| { | ||
| 24 | let bytes = fs::read(&path).unwrap(); | ||
| 25 | (bytes.len() == size) | ||
| 26 | .then(|| (path.file_name().unwrap().to_str().unwrap().into(), bytes)) | ||
| 27 | }) | ||
| 28 | .unwrap() | ||
| 29 | } | ||
| 30 | |||
| 31 | struct Payload(Option<io::Result<Vec<u8>>>); | ||
| 32 | impl notebook::discover::Source for Payload { | ||
| 33 | fn entries(&mut self, _: &str, _: usize) -> io::Result<Vec<notebook::discover::Entry>> { | ||
| 34 | panic!("Unexpected enumeration") | ||
| 35 | } | ||
| 36 | fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> { | ||
| 37 | self.0.take().expect("Unexpected repeated payload read") | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | #[test] | ||
| 42 | fn downloaded_media_survives_migration_reopen_and_recovery_with_the_queue_intact() { | ||
| 43 | let directory = tempfile::tempdir().unwrap(); | ||
| 44 | let original = fs::read(LEGACY).unwrap(); | ||
| 45 | let cache = copied_cache(directory.path()); | ||
| 46 | let working = cache.snapshot().unwrap(); | ||
| 47 | let remote = cache.remote_snapshot().unwrap(); | ||
| 48 | let pending = cache.pending().unwrap(); | ||
| 49 | assert_eq!( | ||
| 50 | pending.iter().map(|edit| edit.id).collect::<Vec<_>>(), | ||
| 51 | [1, 2] | ||
| 52 | ); | ||
| 53 | let uncertain = cache.status(1).unwrap(); | ||
| 54 | assert!(matches!( | ||
| 55 | uncertain, | ||
| 56 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 57 | )); | ||
| 58 | let mut source = notebook::discover::Local::open(FIXTURE).unwrap(); | ||
| 59 | for size in [0, 1024] { | ||
| 60 | let (name, bytes) = payload(size); | ||
| 61 | assert!(cache.cached_asset(&name, size).unwrap().is_none()); | ||
| 62 | assert_eq!( | ||
| 63 | cache | ||
| 64 | .fetch_asset(&mut source, "synthetic.one", &name, size) | ||
| 65 | .unwrap(), | ||
| 66 | bytes | ||
| 67 | ); | ||
| 68 | assert_eq!( | ||
| 69 | cache | ||
| 70 | .cached_asset(&name.to_ascii_lowercase(), size) | ||
| 71 | .unwrap(), | ||
| 72 | Some(bytes) | ||
| 73 | ); | ||
| 74 | } | ||
| 75 | let summary = cache.recovery_summary().unwrap(); | ||
| 76 | assert_eq!( | ||
| 77 | (summary.cached_assets, summary.cached_asset_bytes), | ||
| 78 | (2, 1024) | ||
| 79 | ); | ||
| 80 | assert_eq!(cache.snapshot().unwrap(), working); | ||
| 81 | assert_eq!(cache.remote_snapshot().unwrap(), remote); | ||
| 82 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 83 | assert_eq!(cache.status(1).unwrap(), uncertain); | ||
| 84 | cache | ||
| 85 | .export_recovery(directory.path().join("recovery.sqlite")) | ||
| 86 | .unwrap(); | ||
| 87 | drop(cache); | ||
| 88 | let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap(); | ||
| 89 | let recovery = Recovery::open(directory.path().join("recovery.sqlite")).unwrap(); | ||
| 90 | assert_eq!(recovery.summary().unwrap(), summary); | ||
| 91 | assert_eq!(recovery.pending().unwrap(), pending); | ||
| 92 | assert_eq!(recovery.status(1).unwrap(), uncertain); | ||
| 93 | assert!(recovery.receipts().unwrap().is_empty()); | ||
| 94 | for size in [0, 1024] { | ||
| 95 | let (name, bytes) = payload(size); | ||
| 96 | assert_eq!( | ||
| 97 | cache.cached_asset(&name, size).unwrap(), | ||
| 98 | Some(bytes.clone()) | ||
| 99 | ); | ||
| 100 | assert_eq!(recovery.cached_asset(&name, size).unwrap(), Some(bytes)); | ||
| 101 | } | ||
| 102 | assert_eq!(fs::read(LEGACY).unwrap(), original); | ||
| 103 | } | ||
| 104 | |||
| 105 | #[test] | ||
| 106 | fn an_original_version_four_archive_remains_read_only_and_has_no_cached_media() { | ||
| 107 | let path = "../../corpus/offline-v4/recovery.sqlite"; | ||
| 108 | let before = fs::read(path).unwrap(); | ||
| 109 | let archive = Recovery::open(path).unwrap(); | ||
| 110 | assert_eq!(archive.pending().unwrap().len(), 2); | ||
| 111 | assert!(matches!( | ||
| 112 | archive.status(1).unwrap(), | ||
| 113 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 114 | )); | ||
| 115 | assert_eq!(archive.summary().unwrap().cached_assets, 0); | ||
| 116 | assert_eq!(archive.summary().unwrap().cached_asset_bytes, 0); | ||
| 117 | assert!(archive.cached_asset(&payload(0).0, 0).unwrap().is_none()); | ||
| 118 | drop(archive); | ||
| 119 | assert_eq!(fs::read(path).unwrap(), before); | ||
| 120 | } | ||
| 121 | |||
| 122 | #[test] | ||
| 123 | fn unsuccessful_refreshes_and_changed_identity_data_preserve_the_downloaded_payload() { | ||
| 124 | let directory = tempfile::tempdir().unwrap(); | ||
| 125 | let cache = copied_cache(directory.path()); | ||
| 126 | let (name, bytes) = payload(1024); | ||
| 127 | let mut source = Payload(Some(Ok(bytes.clone()))); | ||
| 128 | cache | ||
| 129 | .fetch_asset(&mut source, "synthetic.one", &name, 1024) | ||
| 130 | .unwrap(); | ||
| 131 | let before = fs::read(directory.path().join("cache.sqlite")).unwrap(); | ||
| 132 | let mut unavailable = Payload(Some(Err(io::ErrorKind::ConnectionReset.into()))); | ||
| 133 | assert!( | ||
| 134 | matches!(cache.fetch_asset(&mut unavailable, "synthetic.one", &name, 1024), | ||
| 135 | Err(Error::Discovery(notebook::discover::Error::Io { error, .. })) if error.kind() == io::ErrorKind::ConnectionReset) | ||
| 136 | ); | ||
| 137 | for size in [0, 1024, 2048] { | ||
| 138 | let mut changed = Payload(Some(Ok(vec![9; size]))); | ||
| 139 | assert!(matches!( | ||
| 140 | cache.fetch_asset(&mut changed, "synthetic.one", &name, 2048), | ||
| 141 | Err(Error::AssetChanged) | ||
| 142 | )); | ||
| 143 | } | ||
| 144 | assert!( | ||
| 145 | matches!(cache.cached_asset(&name, 1023), Err(Error::Io(error)) if error.kind() == io::ErrorKind::FileTooLarge) | ||
| 146 | ); | ||
| 147 | assert_eq!(cache.cached_asset(&name, 1024).unwrap(), Some(bytes)); | ||
| 148 | assert_eq!( | ||
| 149 | fs::read(directory.path().join("cache.sqlite")).unwrap(), | ||
| 150 | before | ||
| 151 | ); | ||
| 152 | } | ||
| 153 | |||
| 154 | #[test] | ||
| 155 | fn unreferenced_payloads_are_rejected_before_io_or_local_changes() { | ||
| 156 | let directory = tempfile::tempdir().unwrap(); | ||
| 157 | let cache = copied_cache(directory.path()); | ||
| 158 | let mut unused = Payload(None); | ||
| 159 | assert!( | ||
| 160 | matches!(cache.fetch_asset(&mut unused, "synthetic.one", "00000000-0000-0000-0000-000000000001.onebin", 100), | ||
| 161 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) | ||
| 162 | ); | ||
| 163 | assert!( | ||
| 164 | cache | ||
| 165 | .fetch_asset(&mut unused, "synthetic.one", "../payload.onebin", 100) | ||
| 166 | .is_err() | ||
| 167 | ); | ||
| 168 | assert_eq!(cache.recovery_summary().unwrap().cached_assets, 0); | ||
| 169 | } | ||
| 170 | |||
| 171 | #[test] | ||
| 172 | fn download_network_wait_does_not_block_local_edits() { | ||
| 173 | struct Waiting { | ||
| 174 | entered: mpsc::Sender<()>, | ||
| 175 | released: mpsc::Receiver<()>, | ||
| 176 | bytes: Vec<u8>, | ||
| 177 | } | ||
| 178 | impl notebook::discover::Source for Waiting { | ||
| 179 | fn entries(&mut self, _: &str, _: usize) -> io::Result<Vec<notebook::discover::Entry>> { | ||
| 180 | panic!("Unexpected enumeration") | ||
| 181 | } | ||
| 182 | fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> { | ||
| 183 | self.entered.send(()).unwrap(); | ||
| 184 | self.released.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 185 | Ok(self.bytes.clone()) | ||
| 186 | } | ||
| 187 | } | ||
| 188 | let directory = tempfile::tempdir().unwrap(); | ||
| 189 | let cache = copied_cache(directory.path()); | ||
| 190 | let (name, bytes) = payload(1024); | ||
| 191 | let (entered, waiting) = mpsc::channel(); | ||
| 192 | let (release, released) = mpsc::channel(); | ||
| 193 | let mut source = Waiting { | ||
| 194 | entered, | ||
| 195 | released, | ||
| 196 | bytes: bytes.clone(), | ||
| 197 | }; | ||
| 198 | std::thread::scope(|scope| { | ||
| 199 | let download = scope.spawn(|| { | ||
| 200 | cache | ||
| 201 | .fetch_asset(&mut source, "synthetic.one", &name, 1024) | ||
| 202 | .unwrap() | ||
| 203 | }); | ||
| 204 | waiting.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 205 | let pending = cache.pending().unwrap(); | ||
| 206 | let Operation::Text(edit) = &pending[0].operation else { | ||
| 207 | panic!() | ||
| 208 | }; | ||
| 209 | let id = cache | ||
| 210 | .edit_text( | ||
| 211 | &cache.snapshot().unwrap(), | ||
| 212 | pending[0].space, | ||
| 213 | edit.object, | ||
| 214 | 0..0, | ||
| 215 | "during download ", | ||
| 216 | ) | ||
| 217 | .unwrap() | ||
| 218 | .unwrap(); | ||
| 219 | release.send(()).unwrap(); | ||
| 220 | assert_eq!(download.join().unwrap(), bytes); | ||
| 221 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 222 | }); | ||
| 223 | } | ||
| 224 | |||
| 225 | #[test] | ||
| 226 | fn concurrent_downloads_publish_one_immutable_cache_entry() { | ||
| 227 | let directory = tempfile::tempdir().unwrap(); | ||
| 228 | let cache = copied_cache(directory.path()); | ||
| 229 | let (name, bytes) = payload(1024); | ||
| 230 | let ready = Barrier::new(8); | ||
| 231 | std::thread::scope(|scope| { | ||
| 232 | for _ in 0..8 { | ||
| 233 | scope.spawn(|| { | ||
| 234 | let mut source = notebook::discover::Local::open(FIXTURE).unwrap(); | ||
| 235 | ready.wait(); | ||
| 236 | assert_eq!( | ||
| 237 | cache | ||
| 238 | .fetch_asset(&mut source, "synthetic.one", &name, 1024) | ||
| 239 | .unwrap(), | ||
| 240 | bytes | ||
| 241 | ); | ||
| 242 | }); | ||
| 243 | } | ||
| 244 | }); | ||
| 245 | assert_eq!(cache.recovery_summary().unwrap().cached_assets, 1); | ||
| 246 | } | ||
| 247 | |||
| 248 | #[test] | ||
| 249 | fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() { | ||
| 250 | struct Native; | ||
| 251 | impl notebook::Remote for Native { | ||
| 252 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 253 | fs::read("../../corpus/native-external-assets/native/synthetic.one") | ||
| 254 | } | ||
| 255 | fn publish(&mut self, _: &onestore::PreparedEdit<'_>) -> Result<(), onestore::CommitError> { | ||
| 256 | panic!("Unexpected publication") | ||
| 257 | } | ||
| 258 | fn confirm(&mut self, _: &[u8]) -> Result<(), onestore::CommitError> { | ||
| 259 | panic!("Unexpected confirmation") | ||
| 260 | } | ||
| 261 | } | ||
| 262 | struct Refresh<'a>(&'a Replica); | ||
| 263 | impl notebook::discover::Source for Refresh<'_> { | ||
| 264 | fn entries(&mut self, _: &str, _: usize) -> io::Result<Vec<notebook::discover::Entry>> { | ||
| 265 | panic!("Unexpected enumeration") | ||
| 266 | } | ||
| 267 | fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> { | ||
| 268 | assert_eq!(self.0.sync_once(&mut Native).unwrap(), None); | ||
| 269 | Ok(payload(1024).1) | ||
| 270 | } | ||
| 271 | } | ||
| 272 | let root = tempfile::tempdir().unwrap(); | ||
| 273 | let source = fs::read(Path::new(FIXTURE).join("synthetic.one")).unwrap(); | ||
| 274 | let cache = Replica::create(root.path().join("cache.sqlite"), &source).unwrap(); | ||
| 275 | let (name, _) = payload(1024); | ||
| 276 | assert!( | ||
| 277 | matches!(cache.fetch_asset(&mut Refresh(&cache), "synthetic.one", &name, 1024), | ||
| 278 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy) | ||
| 279 | ); | ||
| 280 | assert!(cache.cached_asset(&name, 1024).unwrap().is_none()); | ||
| 281 | assert_eq!( | ||
| 282 | cache.snapshot().unwrap(), | ||
| 283 | fs::read("../../corpus/native-external-assets/native/synthetic.one").unwrap() | ||
| 284 | ); | ||
| 285 | } | ||
| 286 | |||
| 287 | #[test] | ||
| 288 | fn local_failure_and_bad_cached_bytes_never_become_successful_downloads() { | ||
| 289 | let directory = tempfile::tempdir().unwrap(); | ||
| 290 | drop(copied_cache(directory.path())); | ||
| 291 | let path = directory.path().join("cache.sqlite"); | ||
| 292 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 293 | connection.execute_batch("CREATE TRIGGER fail_asset BEFORE INSERT ON assets BEGIN SELECT RAISE(ABORT,'Test asset failure'); END").unwrap(); | ||
| 294 | drop(connection); | ||
| 295 | let (name, bytes) = payload(1024); | ||
| 296 | let cache = Replica::open(&path).unwrap(); | ||
| 297 | let mut source = notebook::discover::Local::open(FIXTURE).unwrap(); | ||
| 298 | assert!(matches!( | ||
| 299 | cache.fetch_asset(&mut source, "synthetic.one", &name, 1024), | ||
| 300 | Err(Error::Database(_)) | ||
| 301 | )); | ||
| 302 | assert!(cache.cached_asset(&name, 1024).unwrap().is_none()); | ||
| 303 | assert_eq!(cache.pending().unwrap().len(), 2); | ||
| 304 | drop(cache); | ||
| 305 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 306 | connection.execute_batch("DROP TRIGGER fail_asset").unwrap(); | ||
| 307 | drop(connection); | ||
| 308 | let cache = Replica::open(&path).unwrap(); | ||
| 309 | cache | ||
| 310 | .fetch_asset(&mut source, "synthetic.one", &name, 1024) | ||
| 311 | .unwrap(); | ||
| 312 | drop(cache); | ||
| 313 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 314 | let mut damaged = bytes; | ||
| 315 | damaged[0] ^= 1; | ||
| 316 | connection | ||
| 317 | .execute("UPDATE assets SET data=?1", [damaged]) | ||
| 318 | .unwrap(); | ||
| 319 | drop(connection); | ||
| 320 | let cache = Replica::open(&path).unwrap(); | ||
| 321 | assert!( | ||
| 322 | matches!(cache.cached_asset(&name, 1024), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidData) | ||
| 323 | ); | ||
| 324 | assert!( | ||
| 325 | matches!(cache.fetch_asset(&mut source, "synthetic.one", &name, 1024), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidData) | ||
| 326 | ); | ||
| 327 | cache | ||
| 328 | .export_recovery(directory.path().join("damaged.sqlite")) | ||
| 329 | .unwrap(); | ||
| 330 | let archive = Recovery::open(directory.path().join("damaged.sqlite")).unwrap(); | ||
| 331 | assert!(archive.cached_asset(&name, 1024).is_err()); | ||
| 332 | assert_eq!(archive.pending().unwrap().len(), 2); | ||
| 333 | } | ||
| 334 | |||
| 335 | #[test] | ||
| 336 | fn abrupt_process_exit_retains_only_completed_downloads_and_archives() { | ||
| 337 | const CHILD: &str = "ONESTORE_ASSET_EXIT_CASE"; | ||
| 338 | if let Ok(phase) = std::env::var(CHILD) { | ||
| 339 | struct ExitDuringRead; | ||
| 340 | impl notebook::discover::Source for ExitDuringRead { | ||
| 341 | fn entries(&mut self, _: &str, _: usize) -> io::Result<Vec<notebook::discover::Entry>> { | ||
| 342 | panic!("Unexpected enumeration") | ||
| 343 | } | ||
| 344 | fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> { | ||
| 345 | std::process::exit(83) | ||
| 346 | } | ||
| 347 | } | ||
| 348 | let root = std::env::var("ONESTORE_ASSET_EXIT_ROOT").unwrap(); | ||
| 349 | let cache = copied_cache(Path::new(&root)); | ||
| 350 | let (name, bytes) = payload(1024); | ||
| 351 | if phase == "download" { | ||
| 352 | cache | ||
| 353 | .fetch_asset(&mut ExitDuringRead, "synthetic.one", &name, bytes.len()) | ||
| 354 | .unwrap(); | ||
| 355 | panic!("Read returned after process exit"); | ||
| 356 | } | ||
| 357 | cache | ||
| 358 | .fetch_asset( | ||
| 359 | &mut notebook::discover::Local::open(FIXTURE).unwrap(), | ||
| 360 | "synthetic.one", | ||
| 361 | &name, | ||
| 362 | bytes.len(), | ||
| 363 | ) | ||
| 364 | .unwrap(); | ||
| 365 | if phase == "archive" { | ||
| 366 | cache | ||
| 367 | .export_recovery(Path::new(&root).join("recovery.sqlite")) | ||
| 368 | .unwrap(); | ||
| 369 | } | ||
| 370 | std::process::exit(83); | ||
| 371 | } | ||
| 372 | for phase in ["download", "cached", "archive"] { | ||
| 373 | let root = tempfile::tempdir().unwrap(); | ||
| 374 | let output = std::process::Command::new(std::env::current_exe().unwrap()) | ||
| 375 | .args([ | ||
| 376 | "--exact", | ||
| 377 | "abrupt_process_exit_retains_only_completed_downloads_and_archives", | ||
| 378 | ]) | ||
| 379 | .env(CHILD, phase) | ||
| 380 | .env("ONESTORE_ASSET_EXIT_ROOT", root.path()) | ||
| 381 | .output() | ||
| 382 | .unwrap(); | ||
| 383 | assert_eq!( | ||
| 384 | output.status.code(), | ||
| 385 | Some(83), | ||
| 386 | "{}", | ||
| 387 | String::from_utf8_lossy(&output.stderr) | ||
| 388 | ); | ||
| 389 | let cache = Replica::open(root.path().join("cache.sqlite")).unwrap(); | ||
| 390 | let (name, bytes) = payload(1024); | ||
| 391 | let expected = (phase != "download").then_some(bytes); | ||
| 392 | assert_eq!(cache.cached_asset(&name, 1024).unwrap(), expected); | ||
| 393 | assert_eq!(cache.pending().unwrap().len(), 2); | ||
| 394 | assert!(matches!( | ||
| 395 | cache.status(1).unwrap(), | ||
| 396 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 397 | )); | ||
| 398 | if phase == "archive" { | ||
| 399 | let recovery = Recovery::open(root.path().join("recovery.sqlite")).unwrap(); | ||
| 400 | assert_eq!(recovery.cached_asset(&name, 1024).unwrap(), expected); | ||
| 401 | assert_eq!(recovery.pending().unwrap(), cache.pending().unwrap()); | ||
| 402 | assert_eq!(recovery.status(1).unwrap(), cache.status(1).unwrap()); | ||
| 403 | } | ||
| 404 | } | ||
| 405 | } | ||
| 406 | |||
| 407 | #[test] | ||
| 408 | #[cfg(feature = "smb")] | ||
| 409 | #[ignore = "requires a disposable Samba mirror at ONESTORE_SMB_NOTEBOOK"] | ||
| 410 | fn live_smb_downloads_survive_disconnect_and_cache_reopen() { | ||
| 411 | let root = tempfile::tempdir().unwrap(); | ||
| 412 | let cache = copied_cache(root.path()); | ||
| 413 | let client = notebook::smb::Client::connect( | ||
| 414 | &std::env::var("ONESTORE_SMB_LAB").unwrap(), | ||
| 415 | "agent", | ||
| 416 | notebook::smb::Credentials::default(), | ||
| 417 | Duration::from_secs(5), | ||
| 418 | ) | ||
| 419 | .unwrap(); | ||
| 420 | let notebook = std::env::var("ONESTORE_SMB_NOTEBOOK").unwrap(); | ||
| 421 | let mut source = notebook::discover::Smb::new(&client, &notebook).unwrap(); | ||
| 422 | for size in [0, 771, 1024] { | ||
| 423 | let (name, bytes) = payload(size); | ||
| 424 | assert_eq!( | ||
| 425 | cache | ||
| 426 | .fetch_asset(&mut source, "synthetic.one", &name, size) | ||
| 427 | .unwrap(), | ||
| 428 | bytes | ||
| 429 | ); | ||
| 430 | } | ||
| 431 | drop(client); | ||
| 432 | cache | ||
| 433 | .export_recovery(root.path().join("recovery.sqlite")) | ||
| 434 | .unwrap(); | ||
| 435 | drop(cache); | ||
| 436 | let cache = Replica::open(root.path().join("cache.sqlite")).unwrap(); | ||
| 437 | let recovery = Recovery::open(root.path().join("recovery.sqlite")).unwrap(); | ||
| 438 | for size in [0, 771, 1024] { | ||
| 439 | let (name, bytes) = payload(size); | ||
| 440 | assert_eq!( | ||
| 441 | cache.cached_asset(&name, size).unwrap(), | ||
| 442 | Some(bytes.clone()) | ||
| 443 | ); | ||
| 444 | assert_eq!(recovery.cached_asset(&name, size).unwrap(), Some(bytes)); | ||
| 445 | } | ||
| 446 | assert_eq!(cache.pending().unwrap(), recovery.pending().unwrap()); | ||
| 447 | assert_eq!(cache.status(1).unwrap(), recovery.status(1).unwrap()); | ||
| 448 | assert_eq!(cache.recovery_summary().unwrap().cached_assets, 3); | ||
| 449 | } | ||
crates/notebook/tests/cache.rs created+931| ... | @@ -0,0 +1,931 @@ | ||
| 1 | use notebook::{Error, Replica}; | ||
| 2 | use onestore::{ | ||
| 3 | ExGuid, RevisionIndex, Store, | ||
| 4 | document::{Document, Kind}, | ||
| 5 | }; | ||
| 6 | use std::{ | ||
| 7 | collections::BTreeSet, | ||
| 8 | fs, | ||
| 9 | io::ErrorKind, | ||
| 10 | sync::Barrier, | ||
| 11 | time::{Duration, Instant}, | ||
| 12 | }; | ||
| 13 | |||
| 14 | fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { | ||
| 15 | let store = Store::parse(source).unwrap(); | ||
| 16 | assert!(store.checksum_mismatches.is_empty()); | ||
| 17 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 18 | index.validate_current().unwrap(); | ||
| 19 | let doc = Document::parse(&index).unwrap(); | ||
| 20 | doc.spaces | ||
| 21 | .iter() | ||
| 22 | .find_map(|(sid, space)| { | ||
| 23 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 24 | revision | ||
| 25 | .nodes | ||
| 26 | .iter() | ||
| 27 | .find_map(|(oid, node)| match &node.kind { | ||
| 28 | Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())), | ||
| 29 | _ => None, | ||
| 30 | }) | ||
| 31 | }) | ||
| 32 | .unwrap() | ||
| 33 | } | ||
| 34 | |||
| 35 | #[test] | ||
| 36 | fn cache_reopen_preserves_exact_images_and_intents() { | ||
| 37 | let dir = tempfile::tempdir().unwrap(); | ||
| 38 | let path = dir.path().join("section.sqlite"); | ||
| 39 | let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap(); | ||
| 40 | let replica = Replica::create(&path, &source).unwrap(); | ||
| 41 | assert_eq!(replica.snapshot().unwrap(), source); | ||
| 42 | assert!(replica.pending().unwrap().is_empty()); | ||
| 43 | let (sid, oid, _) = target(&source); | ||
| 44 | assert_eq!( | ||
| 45 | replica.edit_text(&source, sid, oid, 0..0, "").unwrap(), | ||
| 46 | None | ||
| 47 | ); | ||
| 48 | assert_eq!( | ||
| 49 | replica.edit_text(&source, sid, oid, 0..4, "café").unwrap(), | ||
| 50 | None | ||
| 51 | ); | ||
| 52 | let first = replica | ||
| 53 | .edit_text(&source, sid, oid, 5..7, "🐈 日本語") | ||
| 54 | .unwrap() | ||
| 55 | .unwrap(); | ||
| 56 | let edited = replica.snapshot().unwrap(); | ||
| 57 | assert_eq!(target(&edited).2, "café 🐈 日本語"); | ||
| 58 | let intents = replica.pending().unwrap(); | ||
| 59 | assert_eq!(intents.len(), 1); | ||
| 60 | assert_eq!(intents[0].id, first); | ||
| 61 | let notebook::Operation::Text(first_edit) = &intents[0].operation else { | ||
| 62 | panic!() | ||
| 63 | }; | ||
| 64 | assert_eq!(first_edit.before, "café 🦀"); | ||
| 65 | assert_eq!(first_edit.range, 5..7); | ||
| 66 | assert_eq!(first_edit.replacement, "🐈 日本語"); | ||
| 67 | assert_eq!((intents[0].space, first_edit.object), (sid, oid)); | ||
| 68 | drop(replica); | ||
| 69 | let replica = Replica::open(&path).unwrap(); | ||
| 70 | assert_eq!(replica.snapshot().unwrap(), edited); | ||
| 71 | assert_eq!(replica.pending().unwrap(), intents); | ||
| 72 | let second = replica | ||
| 73 | .edit_text(&edited, sid, oid, 0..0, "Recovered ") | ||
| 74 | .unwrap() | ||
| 75 | .unwrap(); | ||
| 76 | assert!(second > first); | ||
| 77 | let notebook::Operation::Text(second_edit) = &replica.pending().unwrap()[1].operation else { | ||
| 78 | panic!() | ||
| 79 | }; | ||
| 80 | assert_eq!(second_edit.before, "café 🐈 日本語"); | ||
| 81 | } | ||
| 82 | |||
| 83 | #[test] | ||
| 84 | fn failed_edits_preserve_both_intent_queue_and_working_image() { | ||
| 85 | let dir = tempfile::tempdir().unwrap(); | ||
| 86 | let path = dir.path().join("section.sqlite"); | ||
| 87 | let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap(); | ||
| 88 | let replica = Replica::create(&path, &source).unwrap(); | ||
| 89 | let (sid, oid, _) = target(&source); | ||
| 90 | for (range, replacement) in [(6..7, "X"), (0..u32::MAX, "X"), (0..1, "\n")] { | ||
| 91 | assert!( | ||
| 92 | replica | ||
| 93 | .edit_text(&source, sid, oid, range, replacement) | ||
| 94 | .is_err() | ||
| 95 | ); | ||
| 96 | assert_eq!(replica.snapshot().unwrap(), source); | ||
| 97 | assert!(replica.pending().unwrap().is_empty()); | ||
| 98 | } | ||
| 99 | drop(replica); | ||
| 100 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 101 | connection.execute_batch("CREATE TRIGGER fail_image BEFORE UPDATE ON replica BEGIN SELECT RAISE(ABORT, 'Injected image update failure'); END;").unwrap(); | ||
| 102 | drop(connection); | ||
| 103 | let replica = Replica::open(&path).unwrap(); | ||
| 104 | assert!(matches!( | ||
| 105 | replica.edit_text(&source, sid, oid, 0..0, "lost? "), | ||
| 106 | Err(Error::Database(_)) | ||
| 107 | )); | ||
| 108 | drop(replica); | ||
| 109 | let replica = Replica::open(&path).unwrap(); | ||
| 110 | assert_eq!(replica.snapshot().unwrap(), source); | ||
| 111 | assert!(replica.pending().unwrap().is_empty()); | ||
| 112 | } | ||
| 113 | |||
| 114 | #[test] | ||
| 115 | fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { | ||
| 116 | use notebook::{Operation, Recovery}; | ||
| 117 | |||
| 118 | let directory = tempfile::tempdir().unwrap(); | ||
| 119 | let source = onestore::create_section("recovery.one", "base", "Fixture").unwrap(); | ||
| 120 | let (space, object, _) = target(&source); | ||
| 121 | let replica = Replica::create(directory.path().join("live.sqlite"), &source).unwrap(); | ||
| 122 | let start = Barrier::new(4); | ||
| 123 | std::thread::scope(|scope| { | ||
| 124 | for writer in 0..3 { | ||
| 125 | let (replica, start) = (&replica, &start); | ||
| 126 | scope.spawn(move || { | ||
| 127 | start.wait(); | ||
| 128 | for edit in 0..20 { | ||
| 129 | loop { | ||
| 130 | let source = replica.snapshot().unwrap(); | ||
| 131 | match replica.edit_text( | ||
| 132 | &source, | ||
| 133 | space, | ||
| 134 | object, | ||
| 135 | 0..0, | ||
| 136 | &format!("[{writer}:{edit}] "), | ||
| 137 | ) { | ||
| 138 | Ok(Some(_)) => break, | ||
| 139 | Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => { | ||
| 140 | continue; | ||
| 141 | } | ||
| 142 | other => panic!("Unexpected local edit: {other:?}"), | ||
| 143 | } | ||
| 144 | } | ||
| 145 | } | ||
| 146 | }); | ||
| 147 | } | ||
| 148 | start.wait(); | ||
| 149 | for n in 0..12 { | ||
| 150 | let path = directory.path().join(format!("recovery-{n}.sqlite")); | ||
| 151 | replica.export_recovery(&path).unwrap(); | ||
| 152 | let archive = Recovery::open(path).unwrap(); | ||
| 153 | let pending = archive.pending().unwrap(); | ||
| 154 | let mut expected = String::new(); | ||
| 155 | for edit in pending.iter().rev() { | ||
| 156 | let Operation::Text(edit) = &edit.operation else { | ||
| 157 | panic!() | ||
| 158 | }; | ||
| 159 | assert_eq!(edit.range, 0..0); | ||
| 160 | expected.push_str(&edit.replacement); | ||
| 161 | } | ||
| 162 | expected.push_str("base"); | ||
| 163 | assert_eq!(target(&archive.snapshot().unwrap()).2, expected); | ||
| 164 | assert_eq!(archive.remote_snapshot().unwrap(), source); | ||
| 165 | assert_eq!( | ||
| 166 | archive.summary().unwrap().queued_edits, | ||
| 167 | pending.len() as u64 | ||
| 168 | ); | ||
| 169 | assert!(archive.receipts().unwrap().is_empty()); | ||
| 170 | } | ||
| 171 | }); | ||
| 172 | assert_eq!(replica.pending().unwrap().len(), 60); | ||
| 173 | } | ||
| 174 | |||
| 175 | #[test] | ||
| 176 | fn ownership_and_foreign_file_rejection_preserve_existing_data() { | ||
| 177 | let dir = tempfile::tempdir().unwrap(); | ||
| 178 | let path = dir.path().join("section.sqlite"); | ||
| 179 | assert!(Replica::open(&path).is_err()); | ||
| 180 | assert!(!path.exists()); | ||
| 181 | assert!(Replica::create(&path, b"invalid").is_err()); | ||
| 182 | assert!(!path.exists()); | ||
| 183 | let source = onestore::create_section("section.one", "Owned", "Fixture").unwrap(); | ||
| 184 | let replica = Replica::create(&path, &source).unwrap(); | ||
| 185 | for _ in 0..3 { | ||
| 186 | assert!( | ||
| 187 | matches!(Replica::open(&path), Err(Error::Database(error)) if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy)) | ||
| 188 | ); | ||
| 189 | assert!( | ||
| 190 | matches!(Replica::create(&path, &source), Err(Error::Io(error)) if error.kind() == ErrorKind::AlreadyExists) | ||
| 191 | ); | ||
| 192 | assert_eq!(replica.snapshot().unwrap(), source); | ||
| 193 | } | ||
| 194 | drop(replica); | ||
| 195 | for sql in [ | ||
| 196 | "PRAGMA application_id=0", | ||
| 197 | "PRAGMA application_id=1330529615; PRAGMA user_version=99", | ||
| 198 | ] { | ||
| 199 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 200 | connection.execute_batch(sql).unwrap(); | ||
| 201 | drop(connection); | ||
| 202 | let before = fs::read(&path).unwrap(); | ||
| 203 | assert!(Replica::open(&path).is_err()); | ||
| 204 | assert_eq!(fs::read(&path).unwrap(), before); | ||
| 205 | } | ||
| 206 | let foreign = dir.path().join("foreign.sqlite"); | ||
| 207 | let connection = rusqlite::Connection::open(&foreign).unwrap(); | ||
| 208 | connection | ||
| 209 | .execute_batch( | ||
| 210 | "CREATE TABLE unrelated (value TEXT); INSERT INTO unrelated VALUES ('preserve');", | ||
| 211 | ) | ||
| 212 | .unwrap(); | ||
| 213 | drop(connection); | ||
| 214 | let before = fs::read(&foreign).unwrap(); | ||
| 215 | assert!(Replica::open(&foreign).is_err()); | ||
| 216 | assert_eq!(fs::read(&foreign).unwrap(), before); | ||
| 217 | let incomplete = dir.path().join("incomplete.sqlite"); | ||
| 218 | fs::write(&incomplete, []).unwrap(); | ||
| 219 | assert!(Replica::open(&incomplete).is_err()); | ||
| 220 | assert_eq!(fs::read(&incomplete).unwrap(), b""); | ||
| 221 | } | ||
| 222 | |||
| 223 | #[test] | ||
| 224 | fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement() { | ||
| 225 | let dir = tempfile::tempdir().unwrap(); | ||
| 226 | let path = dir.path().join("section.sqlite"); | ||
| 227 | let source = onestore::create_section("section.one", "Shared café 🦀", "Fixture").unwrap(); | ||
| 228 | let replica = Replica::create(&path, &source).unwrap(); | ||
| 229 | let (sid, oid, _) = target(&source); | ||
| 230 | let barrier = Barrier::new(12); | ||
| 231 | let deadline = Instant::now() + Duration::from_secs(60); | ||
| 232 | let outcomes = std::thread::scope(|scope| { | ||
| 233 | let handles: Vec<_> = (0..12) | ||
| 234 | .map(|writer| { | ||
| 235 | let (replica, source, barrier) = (&replica, &source, &barrier); | ||
| 236 | scope.spawn(move || { | ||
| 237 | barrier.wait(); | ||
| 238 | let first = | ||
| 239 | replica.edit_text(source, sid, oid, 0..0, &format!("[initial-{writer}] ")); | ||
| 240 | let mut ids = Vec::new(); | ||
| 241 | let first_won = match first { | ||
| 242 | Ok(Some(id)) => { | ||
| 243 | ids.push(id); | ||
| 244 | true | ||
| 245 | } | ||
| 246 | Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => false, | ||
| 247 | other => panic!("Unexpected first edit: {other:?}"), | ||
| 248 | }; | ||
| 249 | for edit in 0..20 { | ||
| 250 | loop { | ||
| 251 | assert!( | ||
| 252 | Instant::now() < deadline, | ||
| 253 | "Writer {writer} stopped progressing at {edit}" | ||
| 254 | ); | ||
| 255 | let source = replica.snapshot().unwrap(); | ||
| 256 | match replica.edit_text( | ||
| 257 | &source, | ||
| 258 | sid, | ||
| 259 | oid, | ||
| 260 | 0..0, | ||
| 261 | &format!("[{writer}-{edit}] "), | ||
| 262 | ) { | ||
| 263 | Ok(Some(id)) => { | ||
| 264 | ids.push(id); | ||
| 265 | break; | ||
| 266 | } | ||
| 267 | Err(Error::Io(error)) | ||
| 268 | if error.kind() == ErrorKind::ResourceBusy => {} | ||
| 269 | other => panic!("Unexpected edit: {other:?}"), | ||
| 270 | } | ||
| 271 | } | ||
| 272 | } | ||
| 273 | (first_won, ids) | ||
| 274 | }) | ||
| 275 | }) | ||
| 276 | .collect(); | ||
| 277 | handles | ||
| 278 | .into_iter() | ||
| 279 | .map(|handle| handle.join().unwrap()) | ||
| 280 | .collect::<Vec<_>>() | ||
| 281 | }); | ||
| 282 | assert_eq!(outcomes.iter().filter(|(won, _)| *won).count(), 1); | ||
| 283 | let ids: BTreeSet<_> = outcomes.into_iter().flat_map(|(_, ids)| ids).collect(); | ||
| 284 | assert_eq!(ids.len(), 241); | ||
| 285 | let final_bytes = replica.snapshot().unwrap(); | ||
| 286 | let content = target(&final_bytes).2; | ||
| 287 | let mut expected = "Shared café 🦀".to_owned(); | ||
| 288 | for pending in replica.pending().unwrap() { | ||
| 289 | let notebook::Operation::Text(edit) = pending.operation else { | ||
| 290 | panic!() | ||
| 291 | }; | ||
| 292 | assert_eq!(edit.before, expected); | ||
| 293 | assert_eq!(edit.range, 0..0); | ||
| 294 | expected.insert_str(0, &edit.replacement); | ||
| 295 | } | ||
| 296 | assert_eq!(content, expected); | ||
| 297 | for writer in 0..12 { | ||
| 298 | for edit in 0..20 { | ||
| 299 | assert_eq!(content.matches(&format!("[{writer}-{edit}] ")).count(), 1); | ||
| 300 | } | ||
| 301 | } | ||
| 302 | assert_eq!( | ||
| 303 | replica | ||
| 304 | .pending() | ||
| 305 | .unwrap() | ||
| 306 | .iter() | ||
| 307 | .map(|edit| edit.id) | ||
| 308 | .collect::<BTreeSet<_>>(), | ||
| 309 | ids | ||
| 310 | ); | ||
| 311 | assert!( | ||
| 312 | matches!(replica.edit_text(&source, sid, oid, 0..0, ""), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy) | ||
| 313 | ); | ||
| 314 | drop(replica); | ||
| 315 | let reopened = Replica::open(&path).unwrap(); | ||
| 316 | assert_eq!(reopened.snapshot().unwrap(), final_bytes); | ||
| 317 | assert_eq!(reopened.pending().unwrap().len(), 241); | ||
| 318 | } | ||
| 319 | |||
| 320 | #[test] | ||
| 321 | fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { | ||
| 322 | let dir = tempfile::tempdir().unwrap(); | ||
| 323 | let path = dir.path().join("model.sqlite"); | ||
| 324 | let mut text = "ab🚀ab🦀 é repeated repeated".to_owned(); | ||
| 325 | let mut source = onestore::create_section("model.one", &text, "Fixture").unwrap(); | ||
| 326 | let mut replica = Replica::create(&path, &source).unwrap(); | ||
| 327 | let (space, object, _) = target(&source); | ||
| 328 | let mut random = 911_u64; | ||
| 329 | let mut next = || { | ||
| 330 | random ^= random << 13; | ||
| 331 | random ^= random >> 7; | ||
| 332 | random ^= random << 17; | ||
| 333 | random | ||
| 334 | }; | ||
| 335 | let mut intents = Vec::new(); | ||
| 336 | for step in 0..1024 { | ||
| 337 | let boundaries: Vec<_> = text | ||
| 338 | .char_indices() | ||
| 339 | .map(|(at, _)| at) | ||
| 340 | .chain([text.len()]) | ||
| 341 | .collect(); | ||
| 342 | let first = boundaries[next() as usize % boundaries.len()]; | ||
| 343 | let last = boundaries[next() as usize % boundaries.len()]; | ||
| 344 | let bytes = first.min(last)..first.max(last); | ||
| 345 | let range = u32::try_from(text[..bytes.start].encode_utf16().count()).unwrap() | ||
| 346 | ..u32::try_from(text[..bytes.end].encode_utf16().count()).unwrap(); | ||
| 347 | let replacement = ["", "🐈", "日本語", "repeated", "é", "ab🦀ab"][next() as usize % 6]; | ||
| 348 | let mut expected = text.clone(); | ||
| 349 | expected.replace_range(bytes, replacement); | ||
| 350 | let acknowledgement = replica | ||
| 351 | .edit_text(&source, space, object, range.clone(), replacement) | ||
| 352 | .unwrap(); | ||
| 353 | if let Some(id) = acknowledgement { | ||
| 354 | assert_ne!(text, expected); | ||
| 355 | assert!( | ||
| 356 | intents | ||
| 357 | .last() | ||
| 358 | .is_none_or(|edit: &notebook::PendingEdit| edit.id < id) | ||
| 359 | ); | ||
| 360 | intents.push(notebook::PendingEdit { | ||
| 361 | id, | ||
| 362 | space, | ||
| 363 | operation: notebook::Operation::Text(notebook::TextEdit { | ||
| 364 | object, | ||
| 365 | before: text, | ||
| 366 | range, | ||
| 367 | replacement: replacement.into(), | ||
| 368 | }), | ||
| 369 | }); | ||
| 370 | assert!( | ||
| 371 | matches!(replica.edit_text(&source, space, object, 0..0, "stale"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy) | ||
| 372 | ); | ||
| 373 | } else { | ||
| 374 | assert_eq!(text, expected); | ||
| 375 | } | ||
| 376 | text = expected; | ||
| 377 | source = replica.snapshot().unwrap(); | ||
| 378 | assert_eq!(target(&source).2, text, "seed 911, step {step}"); | ||
| 379 | if step % 37 == 0 { | ||
| 380 | drop(replica); | ||
| 381 | replica = Replica::open(&path).unwrap(); | ||
| 382 | assert_eq!(replica.snapshot().unwrap(), source); | ||
| 383 | assert_eq!(replica.pending().unwrap(), intents); | ||
| 384 | } | ||
| 385 | } | ||
| 386 | assert!( | ||
| 387 | intents.len() > 512, | ||
| 388 | "The history checkpoint boundary was not exercised" | ||
| 389 | ); | ||
| 390 | drop(replica); | ||
| 391 | let replica = Replica::open(&path).unwrap(); | ||
| 392 | assert_eq!(replica.pending().unwrap(), intents); | ||
| 393 | assert_eq!(replica.snapshot().unwrap(), source); | ||
| 394 | } | ||
| 395 | |||
| 396 | #[test] | ||
| 397 | #[ignore = "migrates a fresh copy of a retained version-two or version-three cache"] | ||
| 398 | fn migrate_retained_cache_copy() { | ||
| 399 | use notebook::{EditStatus, Operation, PendingEdit, TextEdit}; | ||
| 400 | let source = std::path::PathBuf::from(std::env::var_os("ONESTORE_MIGRATION_SOURCE").unwrap()); | ||
| 401 | let output = std::path::PathBuf::from(std::env::var_os("ONESTORE_MIGRATION_OUTPUT").unwrap()); | ||
| 402 | assert!(source.is_absolute() && output.is_absolute()); | ||
| 403 | let mut original = fs::File::open(&source).unwrap(); | ||
| 404 | let mut destination = fs::OpenOptions::new() | ||
| 405 | .write(true) | ||
| 406 | .create_new(true) | ||
| 407 | .open(&output) | ||
| 408 | .unwrap(); | ||
| 409 | std::io::copy(&mut original, &mut destination).unwrap(); | ||
| 410 | destination.sync_all().unwrap(); | ||
| 411 | drop(destination); | ||
| 412 | let db = rusqlite::Connection::open(&output).unwrap(); | ||
| 413 | let version = db | ||
| 414 | .pragma_query_value(None, "user_version", |r| r.get::<_, u32>(0)) | ||
| 415 | .unwrap(); | ||
| 416 | assert!(matches!(version, 2 | 3)); | ||
| 417 | if std::env::var_os("ONESTORE_MIGRATION_ROLLBACK").is_some() { | ||
| 418 | assert_eq!(version, 2); | ||
| 419 | db.pragma_update(None, "foreign_keys", false).unwrap(); | ||
| 420 | db.execute( | ||
| 421 | "INSERT INTO attempt VALUES (1,999999,'{00000001-0000-0000-0000-000000000000},1')", | ||
| 422 | [], | ||
| 423 | ) | ||
| 424 | .unwrap(); | ||
| 425 | drop(db); | ||
| 426 | let before = fs::read(&output).unwrap(); | ||
| 427 | assert!( | ||
| 428 | matches!(Replica::open(&output), Err(Error::Database(rusqlite::Error::SqliteFailure(error, _))) if error.extended_code == 787) | ||
| 429 | ); | ||
| 430 | assert_eq!(fs::read(&output).unwrap(), before); | ||
| 431 | let db = rusqlite::Connection::open(&output).unwrap(); | ||
| 432 | assert_eq!( | ||
| 433 | db.pragma_query_value(None, "user_version", |r| r.get::<_, u32>(0)) | ||
| 434 | .unwrap(), | ||
| 435 | 2 | ||
| 436 | ); | ||
| 437 | println!("migration: rollback preserved {} bytes", before.len()); | ||
| 438 | return; | ||
| 439 | } | ||
| 440 | if std::env::var_os("ONESTORE_MIGRATION_CONFLICT").is_some() { | ||
| 441 | db.execute("INSERT INTO conflicts SELECT min(id),0 FROM edits", []) | ||
| 442 | .unwrap(); | ||
| 443 | } | ||
| 444 | let (base, working): (Vec<u8>, Vec<u8>) = db | ||
| 445 | .query_row("SELECT base,working FROM replica", [], |r| { | ||
| 446 | Ok((r.get(0)?, r.get(1)?)) | ||
| 447 | }) | ||
| 448 | .unwrap(); | ||
| 449 | let sequence: i64 = db | ||
| 450 | .query_row( | ||
| 451 | "SELECT seq FROM sqlite_sequence WHERE name='edits'", | ||
| 452 | [], | ||
| 453 | |r| r.get(0), | ||
| 454 | ) | ||
| 455 | .unwrap(); | ||
| 456 | let pending: Vec<PendingEdit> = | ||
| 457 | if version == 2 { | ||
| 458 | let mut query = db | ||
| 459 | .prepare("SELECT id,space,object,before_text,start,end,replacement FROM edits ORDER BY id") | ||
| 460 | .unwrap(); | ||
| 461 | let pending: Vec<PendingEdit> = query | ||
| 462 | .query_map([], |r| { | ||
| 463 | Ok(PendingEdit { | ||
| 464 | id: u64::try_from(r.get::<_, i64>(0)?).unwrap(), | ||
| 465 | space: r.get::<_, String>(1)?.parse().unwrap(), | ||
| 466 | operation: Operation::Text(TextEdit { | ||
| 467 | object: r.get::<_, String>(2)?.parse().unwrap(), | ||
| 468 | before: r.get(3)?, | ||
| 469 | range: r.get(4)?..r.get(5)?, | ||
| 470 | replacement: r.get(6)?, | ||
| 471 | }), | ||
| 472 | }) | ||
| 473 | }) | ||
| 474 | .unwrap() | ||
| 475 | .collect::<Result<_, _>>() | ||
| 476 | .unwrap(); | ||
| 477 | drop(query); | ||
| 478 | pending | ||
| 479 | } else { | ||
| 480 | let mut query = db | ||
| 481 | .prepare("SELECT id,space,operation FROM edits ORDER BY id") | ||
| 482 | .unwrap(); | ||
| 483 | query | ||
| 484 | .query_map([], |r| { | ||
| 485 | Ok(PendingEdit { | ||
| 486 | id: u64::try_from(r.get::<_, i64>(0)?).unwrap(), | ||
| 487 | space: r.get::<_, String>(1)?.parse().unwrap(), | ||
| 488 | operation: serde_json::from_str(&r.get::<_, String>(2)?).unwrap(), | ||
| 489 | }) | ||
| 490 | }) | ||
| 491 | .unwrap() | ||
| 492 | .collect::<Result<_, _>>() | ||
| 493 | .unwrap() | ||
| 494 | }; | ||
| 495 | let mut states = std::collections::BTreeMap::new(); | ||
| 496 | for edit in &pending { | ||
| 497 | states.insert(edit.id, EditStatus::Pending); | ||
| 498 | } | ||
| 499 | for table in ["receipts", "attempt"] { | ||
| 500 | let mut query = db | ||
| 501 | .prepare(&format!("SELECT edit_id,revision FROM {table}")) | ||
| 502 | .unwrap(); | ||
| 503 | for row in query | ||
| 504 | .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))) | ||
| 505 | .unwrap() | ||
| 506 | { | ||
| 507 | let (id, rid) = row.unwrap(); | ||
| 508 | let revision = rid.parse().unwrap(); | ||
| 509 | states.insert( | ||
| 510 | u64::try_from(id).unwrap(), | ||
| 511 | if table == "receipts" { | ||
| 512 | EditStatus::Published { revision } | ||
| 513 | } else { | ||
| 514 | EditStatus::AwaitingConfirmation { revision } | ||
| 515 | }, | ||
| 516 | ); | ||
| 517 | } | ||
| 518 | } | ||
| 519 | let mut query = db.prepare("SELECT edit_id,kind FROM conflicts").unwrap(); | ||
| 520 | for row in query | ||
| 521 | .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, u32>(1)?))) | ||
| 522 | .unwrap() | ||
| 523 | { | ||
| 524 | let (id, kind) = row.unwrap(); | ||
| 525 | assert_eq!(kind, 0); | ||
| 526 | states.insert( | ||
| 527 | u64::try_from(id).unwrap(), | ||
| 528 | EditStatus::Conflict(notebook::ConflictKind::TextChanged), | ||
| 529 | ); | ||
| 530 | } | ||
| 531 | drop(query); | ||
| 532 | drop(db); | ||
| 533 | let cache = Replica::open(&output).unwrap(); | ||
| 534 | assert_eq!(cache.snapshot().unwrap(), working); | ||
| 535 | assert_eq!(cache.remote_snapshot().unwrap(), base); | ||
| 536 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 537 | for (id, status) in &states { | ||
| 538 | assert_eq!(cache.status(*id).unwrap(), Some(*status)); | ||
| 539 | } | ||
| 540 | drop(cache); | ||
| 541 | let cache = Replica::open(&output).unwrap(); | ||
| 542 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 543 | let store = Store::parse(&working).unwrap(); | ||
| 544 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 545 | let doc = Document::parse(&index).unwrap(); | ||
| 546 | let (sid, page) = doc.pages().unwrap()[0]; | ||
| 547 | let insertion = onestore::Insertion::outline( | ||
| 548 | page, | ||
| 549 | 144.0, | ||
| 550 | 720.0, | ||
| 551 | "After cache migration", | ||
| 552 | "Migration author", | ||
| 553 | ) | ||
| 554 | .unwrap(); | ||
| 555 | let next = cache.insert(&working, sid, &insertion).unwrap().unwrap(); | ||
| 556 | assert_eq!(next, u64::try_from(sequence + 1).unwrap()); | ||
| 557 | let updated = cache.snapshot().unwrap(); | ||
| 558 | drop(cache); | ||
| 559 | let cache = Replica::open(&output).unwrap(); | ||
| 560 | assert_eq!(cache.snapshot().unwrap(), updated); | ||
| 561 | assert_eq!( | ||
| 562 | cache.pending().unwrap().last().unwrap().operation, | ||
| 563 | Operation::Insert(insertion) | ||
| 564 | ); | ||
| 565 | println!( | ||
| 566 | "migration: {}", | ||
| 567 | serde_json::json!({"pending":pending.len(),"retained_statuses":states.len(),"next_id":next,"base_bytes":base.len(),"working_bytes":working.len()}) | ||
| 568 | ); | ||
| 569 | } | ||
| 570 | |||
| 571 | #[test] | ||
| 572 | fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { | ||
| 573 | use onestore::Insertion; | ||
| 574 | let directory = tempfile::tempdir().unwrap(); | ||
| 575 | let path = directory.path().join("parallel.sqlite"); | ||
| 576 | let source = onestore::create_section("parallel.one", "Original", "Author").unwrap(); | ||
| 577 | let store = Store::parse(&source).unwrap(); | ||
| 578 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 579 | let doc = Document::parse(&index).unwrap(); | ||
| 580 | let (sid, page) = doc.pages().unwrap()[0]; | ||
| 581 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 582 | let barrier = Barrier::new(12); | ||
| 583 | let deadline = Instant::now() + Duration::from_secs(90); | ||
| 584 | let all = std::thread::scope(|scope| { | ||
| 585 | let handles: Vec<_> = (0..12) | ||
| 586 | .map(|client| { | ||
| 587 | let (cache, barrier) = (&cache, &barrier); | ||
| 588 | scope.spawn(move || { | ||
| 589 | let outline = Insertion::outline( | ||
| 590 | page, | ||
| 591 | 72.0, | ||
| 592 | 144.0 + client as f32 * 72.0, | ||
| 593 | &format!("Client {client}"), | ||
| 594 | "Author", | ||
| 595 | ) | ||
| 596 | .unwrap(); | ||
| 597 | let mut ids = Vec::new(); | ||
| 598 | let mut objects = Vec::new(); | ||
| 599 | barrier.wait(); | ||
| 600 | for sequence in 0..4 { | ||
| 601 | let insertion = if sequence == 0 { | ||
| 602 | outline.clone() | ||
| 603 | } else { | ||
| 604 | Insertion::paragraph( | ||
| 605 | outline.object(), | ||
| 606 | None, | ||
| 607 | &format!("Paragraph {client}:{sequence}"), | ||
| 608 | "Author", | ||
| 609 | ) | ||
| 610 | .unwrap() | ||
| 611 | } | ||
| 612 | .with_formatting(0..6, &[onestore::TextAttribute::Italic(true)]) | ||
| 613 | .unwrap(); | ||
| 614 | loop { | ||
| 615 | assert!( | ||
| 616 | Instant::now() < deadline, | ||
| 617 | "Client {client} stopped at insertion {sequence}" | ||
| 618 | ); | ||
| 619 | let snapshot = cache.snapshot().unwrap(); | ||
| 620 | match cache.insert(&snapshot, sid, &insertion) { | ||
| 621 | Ok(Some(id)) => { | ||
| 622 | ids.push(id); | ||
| 623 | objects.push(insertion.text_object()); | ||
| 624 | break; | ||
| 625 | } | ||
| 626 | Err(Error::Io(e)) if e.kind() == ErrorKind::ResourceBusy => {} | ||
| 627 | other => panic!("{other:?}"), | ||
| 628 | } | ||
| 629 | } | ||
| 630 | if sequence == 0 { | ||
| 631 | continue; | ||
| 632 | } | ||
| 633 | loop { | ||
| 634 | assert!( | ||
| 635 | Instant::now() < deadline, | ||
| 636 | "Client {client} stopped at text {sequence}" | ||
| 637 | ); | ||
| 638 | let snapshot = cache.snapshot().unwrap(); | ||
| 639 | match cache.edit_text( | ||
| 640 | &snapshot, | ||
| 641 | sid, | ||
| 642 | insertion.text_object(), | ||
| 643 | 0..0, | ||
| 644 | "Edited ", | ||
| 645 | ) { | ||
| 646 | Ok(Some(id)) => { | ||
| 647 | ids.push(id); | ||
| 648 | break; | ||
| 649 | } | ||
| 650 | Err(Error::Io(e)) if e.kind() == ErrorKind::ResourceBusy => {} | ||
| 651 | other => panic!("{other:?}"), | ||
| 652 | } | ||
| 653 | } | ||
| 654 | } | ||
| 655 | (ids, objects) | ||
| 656 | }) | ||
| 657 | }) | ||
| 658 | .collect(); | ||
| 659 | handles | ||
| 660 | .into_iter() | ||
| 661 | .map(|h| h.join().unwrap()) | ||
| 662 | .collect::<Vec<_>>() | ||
| 663 | }); | ||
| 664 | let ids: BTreeSet<_> = all | ||
| 665 | .iter() | ||
| 666 | .flat_map(|(ids, _)| ids.iter().copied()) | ||
| 667 | .collect(); | ||
| 668 | assert_eq!(ids.len(), 84); | ||
| 669 | let snapshot = cache.snapshot().unwrap(); | ||
| 670 | drop(cache); | ||
| 671 | let cache = Replica::open(&path).unwrap(); | ||
| 672 | assert_eq!(cache.snapshot().unwrap(), snapshot); | ||
| 673 | assert_eq!( | ||
| 674 | cache | ||
| 675 | .pending() | ||
| 676 | .unwrap() | ||
| 677 | .iter() | ||
| 678 | .map(|e| e.id) | ||
| 679 | .collect::<BTreeSet<_>>(), | ||
| 680 | ids | ||
| 681 | ); | ||
| 682 | let store = Store::parse(&snapshot).unwrap(); | ||
| 683 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 684 | let doc = Document::parse(&index).unwrap(); | ||
| 685 | let s = &doc.spaces[&sid]; | ||
| 686 | let v = &s.revisions[&s.contexts[&ExGuid::default()]]; | ||
| 687 | for (client, (_, objects)) in all.iter().enumerate() { | ||
| 688 | for (sequence, id) in objects.iter().enumerate() { | ||
| 689 | let wanted = if sequence == 0 { | ||
| 690 | format!("Client {client}") | ||
| 691 | } else { | ||
| 692 | format!("Edited Paragraph {client}:{sequence}") | ||
| 693 | }; | ||
| 694 | assert!(matches!(&v.nodes[id].kind,Kind::RichText{text,..} if *text==wanted)); | ||
| 695 | let runs = v.text_runs(*id).unwrap(); | ||
| 696 | assert_eq!(runs[0].format.italic, Some(true)); | ||
| 697 | assert_eq!( | ||
| 698 | runs[0].text, | ||
| 699 | if sequence == 0 { | ||
| 700 | "Client" | ||
| 701 | } else { | ||
| 702 | "Edited Paragr" | ||
| 703 | } | ||
| 704 | ); | ||
| 705 | assert!(runs[1..].iter().all(|run| run.format.italic != Some(true))); | ||
| 706 | } | ||
| 707 | } | ||
| 708 | } | ||
| 709 | |||
| 710 | #[test] | ||
| 711 | fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { | ||
| 712 | for operation in ["Text", "Insert", "Format", "Split", "Join", "Outline"] { | ||
| 713 | let directory = tempfile::tempdir().unwrap(); | ||
| 714 | let path = directory.path().join("unknown.sqlite"); | ||
| 715 | let mut source = onestore::create_section("unknown.one", "Original", "Author").unwrap(); | ||
| 716 | let (sid, oid, _) = target(&source); | ||
| 717 | let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap(); | ||
| 718 | if operation == "Join" { | ||
| 719 | source = onestore::PreparedEdit::split(&source, sid, &split) | ||
| 720 | .unwrap() | ||
| 721 | .as_bytes() | ||
| 722 | .to_vec(); | ||
| 723 | } | ||
| 724 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 725 | if operation == "Insert" { | ||
| 726 | let store = Store::parse(&source).unwrap(); | ||
| 727 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 728 | let doc = Document::parse(&index).unwrap(); | ||
| 729 | let (_, page) = doc.pages().unwrap()[0]; | ||
| 730 | let insertion = | ||
| 731 | onestore::Insertion::outline(page, 144.0, 144.0, "Inserted", "Author").unwrap(); | ||
| 732 | cache.insert(&source, sid, &insertion).unwrap(); | ||
| 733 | } else if operation == "Outline" { | ||
| 734 | let store = Store::parse(&source).unwrap(); | ||
| 735 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 736 | let doc = Document::parse(&index).unwrap(); | ||
| 737 | let space = &doc.spaces[&sid]; | ||
| 738 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 739 | let paragraph = *view | ||
| 740 | .nodes | ||
| 741 | .iter() | ||
| 742 | .find(|(_, node)| node.content == [oid]) | ||
| 743 | .unwrap() | ||
| 744 | .0; | ||
| 745 | cache | ||
| 746 | .outline( | ||
| 747 | &source, | ||
| 748 | sid, | ||
| 749 | paragraph, | ||
| 750 | onestore::OutlineEdit::Collapsed(true), | ||
| 751 | ) | ||
| 752 | .unwrap(); | ||
| 753 | } else if operation == "Text" { | ||
| 754 | cache.edit_text(&source, sid, oid, 0..0, "New ").unwrap(); | ||
| 755 | } else if operation == "Split" { | ||
| 756 | cache.split(&source, sid, &split).unwrap(); | ||
| 757 | } else if operation == "Join" { | ||
| 758 | cache | ||
| 759 | .join( | ||
| 760 | &source, | ||
| 761 | sid, | ||
| 762 | &onestore::ParagraphJoin::new(oid, split.text_object(), "Author").unwrap(), | ||
| 763 | ) | ||
| 764 | .unwrap(); | ||
| 765 | } else { | ||
| 766 | cache | ||
| 767 | .format( | ||
| 768 | &source, | ||
| 769 | sid, | ||
| 770 | oid, | ||
| 771 | 0..4, | ||
| 772 | &[onestore::TextAttribute::Bold(true)], | ||
| 773 | ) | ||
| 774 | .unwrap(); | ||
| 775 | } | ||
| 776 | drop(cache); | ||
| 777 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 778 | let encoded: String = db | ||
| 779 | .query_row("SELECT operation FROM edits", [], |r| r.get(0)) | ||
| 780 | .unwrap(); | ||
| 781 | let mut value: serde_json::Value = serde_json::from_str(&encoded).unwrap(); | ||
| 782 | value[operation]["future_option"] = true.into(); | ||
| 783 | db.execute("UPDATE edits SET operation=?1", [value.to_string()]) | ||
| 784 | .unwrap(); | ||
| 785 | if matches!(operation, "Text" | "Insert") { | ||
| 786 | db.execute_batch("ALTER TABLE attempt RENAME COLUMN revisions TO revision; DROP TABLE assets; DROP TABLE conflicts; CREATE TABLE conflicts (edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 2)) STRICT; PRAGMA user_version=3;").unwrap(); | ||
| 787 | } | ||
| 788 | drop(db); | ||
| 789 | let before = fs::read(&path).unwrap(); | ||
| 790 | assert!( | ||
| 791 | matches!(Replica::open(&path),Err(Error::Io(error))if error.kind()==ErrorKind::InvalidData) | ||
| 792 | ); | ||
| 793 | assert_eq!(fs::read(&path).unwrap(), before); | ||
| 794 | } | ||
| 795 | } | ||
| 796 | |||
| 797 | #[test] | ||
| 798 | fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_conflicts() { | ||
| 799 | for (version, ceiling) in [(5, 3), (6, 4), (7, 5), (8, 6), (9, 6)] { | ||
| 800 | use notebook::{ConflictKind, EditStatus, Recovery}; | ||
| 801 | use sha2::{Digest, Sha256}; | ||
| 802 | let directory = tempfile::tempdir().unwrap(); | ||
| 803 | let path = directory.path().join("previous.sqlite"); | ||
| 804 | let source = onestore::create_section("migration.one", "Original", "Author").unwrap(); | ||
| 805 | let (sid, oid, _) = target(&source); | ||
| 806 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 807 | let first = cache | ||
| 808 | .edit_text(&source, sid, oid, 0..0, "New ") | ||
| 809 | .unwrap() | ||
| 810 | .unwrap(); | ||
| 811 | let second = cache | ||
| 812 | .format( | ||
| 813 | &cache.snapshot().unwrap(), | ||
| 814 | sid, | ||
| 815 | oid, | ||
| 816 | 0..3, | ||
| 817 | &[onestore::TextAttribute::Bold(true)], | ||
| 818 | ) | ||
| 819 | .unwrap() | ||
| 820 | .unwrap(); | ||
| 821 | let queue = cache.pending().unwrap(); | ||
| 822 | let local = cache.snapshot().unwrap(); | ||
| 823 | let store = Store::parse(&local).unwrap(); | ||
| 824 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 825 | let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; | ||
| 826 | drop(cache); | ||
| 827 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 828 | let current_version: u32 = db | ||
| 829 | .pragma_query_value(None, "user_version", |row| row.get(0)) | ||
| 830 | .unwrap(); | ||
| 831 | let asset = "00112233-4455-6677-8899-aabbccddeeff.onebin"; | ||
| 832 | let data = b"retained media"; | ||
| 833 | db.execute( | ||
| 834 | "INSERT INTO assets VALUES (?1,?2,?3)", | ||
| 835 | rusqlite::params![asset, data.as_slice(), Sha256::digest(data).as_slice()], | ||
| 836 | ) | ||
| 837 | .unwrap(); | ||
| 838 | db.execute_batch(&format!( | ||
| 839 | "ALTER TABLE attempt RENAME COLUMN revisions TO revision; | ||
| 840 | DROP TABLE conflicts; CREATE TABLE conflicts ( | ||
| 841 | edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, | ||
| 842 | kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND {ceiling})) STRICT; | ||
| 843 | PRAGMA user_version={version}; | ||
| 844 | UPDATE sqlite_sequence SET seq=1000 WHERE name='edits';" | ||
| 845 | )) | ||
| 846 | .unwrap(); | ||
| 847 | db.execute( | ||
| 848 | "INSERT INTO conflicts VALUES (?1,3)", | ||
| 849 | [i64::try_from(second).unwrap()], | ||
| 850 | ) | ||
| 851 | .unwrap(); | ||
| 852 | db.execute( | ||
| 853 | "INSERT INTO attempt VALUES (1,?1,?2)", | ||
| 854 | rusqlite::params![i64::try_from(first).unwrap(), revision.to_string()], | ||
| 855 | ) | ||
| 856 | .unwrap(); | ||
| 857 | db.execute( | ||
| 858 | "INSERT INTO receipts VALUES (999,?1)", | ||
| 859 | [revision.to_string()], | ||
| 860 | ) | ||
| 861 | .unwrap(); | ||
| 862 | drop(db); | ||
| 863 | let archive = directory.path().join("legacy-recovery.sqlite"); | ||
| 864 | std::fs::copy(&path, &archive).unwrap(); | ||
| 865 | let archive_db = rusqlite::Connection::open(&archive).unwrap(); | ||
| 866 | archive_db | ||
| 867 | .pragma_update(None, "application_id", 0x4f4e4552) | ||
| 868 | .unwrap(); | ||
| 869 | drop(archive_db); | ||
| 870 | let original_archive = std::fs::read(&archive).unwrap(); | ||
| 871 | let recovery = Recovery::open(&archive).unwrap(); | ||
| 872 | assert_eq!( | ||
| 873 | recovery.status(first).unwrap(), | ||
| 874 | Some(EditStatus::AwaitingConfirmation { revision }) | ||
| 875 | ); | ||
| 876 | assert_eq!(recovery.pending().unwrap(), queue); | ||
| 877 | drop(recovery); | ||
| 878 | assert!(std::fs::read(&archive).unwrap() == original_archive); | ||
| 879 | let cache = Replica::open(&path).unwrap(); | ||
| 880 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 881 | assert_eq!( | ||
| 882 | cache.cached_asset(asset, 1024).unwrap(), | ||
| 883 | Some(data.to_vec()) | ||
| 884 | ); | ||
| 885 | assert!(cache.snapshot().unwrap() == local); | ||
| 886 | assert!(cache.remote_snapshot().unwrap() == source); | ||
| 887 | assert_eq!( | ||
| 888 | cache.status(first).unwrap(), | ||
| 889 | Some(EditStatus::AwaitingConfirmation { revision }) | ||
| 890 | ); | ||
| 891 | assert_eq!( | ||
| 892 | cache.status(second).unwrap(), | ||
| 893 | Some(EditStatus::Conflict(ConflictKind::FormattingChanged)) | ||
| 894 | ); | ||
| 895 | assert_eq!( | ||
| 896 | cache.status(999).unwrap(), | ||
| 897 | Some(EditStatus::Published { revision }) | ||
| 898 | ); | ||
| 899 | let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap(); | ||
| 900 | assert_eq!(cache.split(&local, sid, &split).unwrap(), Some(1001)); | ||
| 901 | let pending = cache.pending().unwrap(); | ||
| 902 | let archive = directory.path().join("recovery.sqlite"); | ||
| 903 | cache.export_recovery(&archive).unwrap(); | ||
| 904 | let recovery = Recovery::open(&archive).unwrap(); | ||
| 905 | assert_eq!(recovery.pending().unwrap(), pending); | ||
| 906 | assert_eq!( | ||
| 907 | recovery.cached_asset(asset, 1024).unwrap(), | ||
| 908 | Some(data.to_vec()) | ||
| 909 | ); | ||
| 910 | assert_eq!( | ||
| 911 | recovery.status(first).unwrap(), | ||
| 912 | cache.status(first).unwrap() | ||
| 913 | ); | ||
| 914 | assert_eq!(recovery.receipts().unwrap().get(&999), Some(&revision)); | ||
| 915 | drop(cache); | ||
| 916 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 917 | assert_eq!( | ||
| 918 | db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) | ||
| 919 | .unwrap(), | ||
| 920 | current_version | ||
| 921 | ); | ||
| 922 | db.execute("INSERT INTO conflicts VALUES (1001,6)", []) | ||
| 923 | .unwrap(); | ||
| 924 | drop(db); | ||
| 925 | let cache = Replica::open(&path).unwrap(); | ||
| 926 | assert_eq!( | ||
| 927 | cache.status(1001).unwrap(), | ||
| 928 | Some(EditStatus::Conflict(ConflictKind::ContentChanged)) | ||
| 929 | ); | ||
| 930 | } | ||
| 931 | } | ||
crates/notebook/tests/discovery.rs created+280| ... | @@ -0,0 +1,280 @@ | ||
| 1 | use notebook::discover::{Entry, Error, Limits, Local, Source, discover}; | ||
| 2 | use std::{fs, io, path::Path}; | ||
| 3 | |||
| 4 | fn limits() -> Limits { | ||
| 5 | Limits { | ||
| 6 | entries: 1000, | ||
| 7 | bytes_per_file: 16 * 1024 * 1024, | ||
| 8 | depth: 16, | ||
| 9 | } | ||
| 10 | } | ||
| 11 | |||
| 12 | fn fixture(root: &Path) -> Vec<u8> { | ||
| 13 | let section = onestore::create_section("one.one", "Retained 🦀", "Fixture").unwrap(); | ||
| 14 | fs::write(root.join("one.one"), &section).unwrap(); | ||
| 15 | let identity = onestore::Store::parse(&section).unwrap().header.file_id; | ||
| 16 | let toc = onestore::create_table_of_contents("Open Notebook.onetoc2", &[("one.one", identity)]) | ||
| 17 | .unwrap(); | ||
| 18 | fs::write(root.join("Open Notebook.onetoc2"), toc).unwrap(); | ||
| 19 | section | ||
| 20 | } | ||
| 21 | |||
| 22 | #[test] | ||
| 23 | fn rename_preserves_identity_and_does_not_trust_a_stale_cached_filename() { | ||
| 24 | let root = tempfile::tempdir().unwrap(); | ||
| 25 | let section = fixture(root.path()); | ||
| 26 | let mut source = Local::open(root.path()).unwrap(); | ||
| 27 | let before = discover(&mut source, limits()).unwrap(); | ||
| 28 | fs::rename( | ||
| 29 | root.path().join("one.one"), | ||
| 30 | root.path().join("Renamed 🦀.ONE"), | ||
| 31 | ) | ||
| 32 | .unwrap(); | ||
| 33 | let after = discover(&mut source, limits()).unwrap(); | ||
| 34 | assert_eq!(before.sections[0].file_id, after.sections[0].file_id); | ||
| 35 | assert_eq!(after.sections[0].path, "Renamed 🦀.ONE"); | ||
| 36 | assert!(after.toc.as_ref().unwrap().unresolved.is_empty()); | ||
| 37 | assert_eq!( | ||
| 38 | fs::read(root.path().join("Renamed 🦀.ONE")).unwrap(), | ||
| 39 | section | ||
| 40 | ); | ||
| 41 | fs::write( | ||
| 42 | root.path().join("one.one"), | ||
| 43 | onestore::create_section("one.one", "Different", "Fixture").unwrap(), | ||
| 44 | ) | ||
| 45 | .unwrap(); | ||
| 46 | let with_replacement = discover(&mut source, limits()).unwrap(); | ||
| 47 | assert_eq!( | ||
| 48 | with_replacement.sections[0].file_id, | ||
| 49 | before.sections[0].file_id | ||
| 50 | ); | ||
| 51 | assert_eq!(with_replacement.sections[0].path, "Renamed 🦀.ONE"); | ||
| 52 | assert_ne!( | ||
| 53 | with_replacement.sections[1].file_id, | ||
| 54 | before.sections[0].file_id | ||
| 55 | ); | ||
| 56 | fs::remove_file(root.path().join("Renamed 🦀.ONE")).unwrap(); | ||
| 57 | let absent = discover(&mut source, limits()).unwrap(); | ||
| 58 | assert_eq!(absent.toc.as_ref().unwrap().unresolved.len(), 1); | ||
| 59 | assert_eq!( | ||
| 60 | absent.toc.as_ref().unwrap().unresolved[0].file, | ||
| 61 | before.sections[0].file_id | ||
| 62 | ); | ||
| 63 | assert_ne!( | ||
| 64 | absent.sections[0].file_id, | ||
| 65 | absent.toc.as_ref().unwrap().unresolved[0].file | ||
| 66 | ); | ||
| 67 | } | ||
| 68 | |||
| 69 | #[test] | ||
| 70 | fn copied_identities_are_ambiguous_even_across_different_groups() { | ||
| 71 | let root = tempfile::tempdir().unwrap(); | ||
| 72 | fixture(root.path()); | ||
| 73 | fs::create_dir(root.path().join("group")).unwrap(); | ||
| 74 | fs::copy( | ||
| 75 | root.path().join("one.one"), | ||
| 76 | root.path().join("group/other.one"), | ||
| 77 | ) | ||
| 78 | .unwrap(); | ||
| 79 | assert!( | ||
| 80 | matches!(discover(&mut Local::open(root.path()).unwrap(), limits()), | ||
| 81 | Err(Error::DuplicateIdentity { first, second, .. }) | ||
| 82 | if [first.as_str(), second.as_str()].contains(&"one.one") | ||
| 83 | && [first.as_str(), second.as_str()].contains(&"group/other.one")) | ||
| 84 | ); | ||
| 85 | } | ||
| 86 | |||
| 87 | #[test] | ||
| 88 | fn locked_and_unreadable_sections_retain_their_storage_identity() { | ||
| 89 | let root = tempfile::tempdir().unwrap(); | ||
| 90 | for (name, fixture) in [ | ||
| 91 | ( | ||
| 92 | "locked.one", | ||
| 93 | "native-encrypted/encrypted-01/notebook/synthetic.one", | ||
| 94 | ), | ||
| 95 | ( | ||
| 96 | "stub.one", | ||
| 97 | "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one", | ||
| 98 | ), | ||
| 99 | ] { | ||
| 100 | fs::copy( | ||
| 101 | Path::new("../../corpus").join(fixture), | ||
| 102 | root.path().join(name), | ||
| 103 | ) | ||
| 104 | .unwrap(); | ||
| 105 | } | ||
| 106 | let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap(); | ||
| 107 | assert!(catalog.toc.is_none()); | ||
| 108 | assert!(matches!( | ||
| 109 | catalog.sections[0].state, | ||
| 110 | notebook::discover::SectionState::Locked | ||
| 111 | )); | ||
| 112 | assert!(matches!( | ||
| 113 | catalog.sections[1].state, | ||
| 114 | notebook::discover::SectionState::Unreadable(_) | ||
| 115 | )); | ||
| 116 | for section in catalog.sections { | ||
| 117 | let bytes = fs::read(root.path().join(section.path)).unwrap(); | ||
| 118 | assert_eq!( | ||
| 119 | section.file_id, | ||
| 120 | onestore::Store::parse(&bytes).unwrap().header.file_id | ||
| 121 | ); | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | #[test] | ||
| 126 | fn a_group_rename_retains_toc_identity_and_parent_order() { | ||
| 127 | let root = tempfile::tempdir().unwrap(); | ||
| 128 | let native = Path::new("../../corpus/m6/native-features-01/notebook"); | ||
| 129 | for relative in [ | ||
| 130 | "Open Notebook.onetoc2", | ||
| 131 | "Group A/Open Notebook.onetoc2", | ||
| 132 | "Group A/Duplicate.one", | ||
| 133 | "Group B/Open Notebook.onetoc2", | ||
| 134 | "Group B/Nested/Open Notebook.onetoc2", | ||
| 135 | "Group B/Nested/Duplicate.one", | ||
| 136 | ] { | ||
| 137 | let target = root.path().join(relative); | ||
| 138 | fs::create_dir_all(target.parent().unwrap()).unwrap(); | ||
| 139 | fs::copy(native.join(relative), target).unwrap(); | ||
| 140 | } | ||
| 141 | let before = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap(); | ||
| 142 | assert_eq!(before.groups[0].path, "Group A"); | ||
| 143 | fs::rename(root.path().join("Group A"), root.path().join("Renamed 🦀")).unwrap(); | ||
| 144 | let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap(); | ||
| 145 | assert_eq!(catalog.groups[0].path, "Renamed 🦀"); | ||
| 146 | assert_eq!( | ||
| 147 | catalog.groups[0].toc.as_ref().unwrap().file_id, | ||
| 148 | before.groups[0].toc.as_ref().unwrap().file_id | ||
| 149 | ); | ||
| 150 | assert_eq!(catalog.groups[1].path, "Group B"); | ||
| 151 | assert_eq!( | ||
| 152 | catalog.groups[0].sections[0].path, | ||
| 153 | "Renamed 🦀/Duplicate.one" | ||
| 154 | ); | ||
| 155 | assert_eq!( | ||
| 156 | serde_json::to_value(&catalog.toc.unwrap().unresolved).unwrap(), | ||
| 157 | serde_json::to_value(&before.toc.unwrap().unresolved).unwrap() | ||
| 158 | ); | ||
| 159 | } | ||
| 160 | |||
| 161 | #[test] | ||
| 162 | fn limits_and_incomplete_files_do_not_produce_a_catalog() { | ||
| 163 | let root = tempfile::tempdir().unwrap(); | ||
| 164 | fixture(root.path()); | ||
| 165 | let mut source = Local::open(root.path()).unwrap(); | ||
| 166 | assert!( | ||
| 167 | discover( | ||
| 168 | &mut source, | ||
| 169 | Limits { | ||
| 170 | entries: 1, | ||
| 171 | ..limits() | ||
| 172 | } | ||
| 173 | ) | ||
| 174 | .is_err() | ||
| 175 | ); | ||
| 176 | assert!( | ||
| 177 | discover( | ||
| 178 | &mut source, | ||
| 179 | Limits { | ||
| 180 | bytes_per_file: 8, | ||
| 181 | ..limits() | ||
| 182 | } | ||
| 183 | ) | ||
| 184 | .is_err() | ||
| 185 | ); | ||
| 186 | fs::create_dir(root.path().join("group")).unwrap(); | ||
| 187 | assert!(matches!( | ||
| 188 | discover( | ||
| 189 | &mut source, | ||
| 190 | Limits { | ||
| 191 | depth: 0, | ||
| 192 | ..limits() | ||
| 193 | } | ||
| 194 | ), | ||
| 195 | Err(Error::Limit { .. }) | ||
| 196 | )); | ||
| 197 | fs::write(root.path().join("one.one"), b"unfinished").unwrap(); | ||
| 198 | assert!( | ||
| 199 | matches!(discover(&mut source, limits()), Err(Error::Document { path, .. }) if path == "one.one") | ||
| 200 | ); | ||
| 201 | } | ||
| 202 | |||
| 203 | #[test] | ||
| 204 | fn a_failed_refresh_retains_the_previous_catalog_as_an_independent_value() { | ||
| 205 | struct Changing { | ||
| 206 | source: Local, | ||
| 207 | reads: usize, | ||
| 208 | fail: bool, | ||
| 209 | } | ||
| 210 | impl Source for Changing { | ||
| 211 | fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> { | ||
| 212 | self.reads += 1; | ||
| 213 | let mut entries = self.source.entries(path, limit)?; | ||
| 214 | if self.reads == 2 { | ||
| 215 | if self.fail { | ||
| 216 | return Err(io::ErrorKind::ConnectionAborted.into()); | ||
| 217 | } | ||
| 218 | entries[0].name.push_str(".renamed"); | ||
| 219 | } | ||
| 220 | Ok(entries) | ||
| 221 | } | ||
| 222 | fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 223 | self.source.read(path, limit) | ||
| 224 | } | ||
| 225 | } | ||
| 226 | let root = tempfile::tempdir().unwrap(); | ||
| 227 | fixture(root.path()); | ||
| 228 | let mut source = Local::open(root.path()).unwrap(); | ||
| 229 | let catalog = discover(&mut source, limits()).unwrap(); | ||
| 230 | let before = serde_json::to_value(&catalog).unwrap(); | ||
| 231 | for fail in [false, true] { | ||
| 232 | let mut changing = Changing { | ||
| 233 | source: Local::open(root.path()).unwrap(), | ||
| 234 | reads: 0, | ||
| 235 | fail, | ||
| 236 | }; | ||
| 237 | let error = discover(&mut changing, limits()).unwrap_err(); | ||
| 238 | if fail { | ||
| 239 | assert!( | ||
| 240 | matches!(error, Error::Io { error, .. } if error.kind() == io::ErrorKind::ConnectionAborted) | ||
| 241 | ); | ||
| 242 | } else { | ||
| 243 | assert!(matches!(error, Error::Changed { .. })); | ||
| 244 | } | ||
| 245 | assert_eq!(serde_json::to_value(&catalog).unwrap(), before); | ||
| 246 | } | ||
| 247 | } | ||
| 248 | |||
| 249 | #[test] | ||
| 250 | fn local_access_stays_inside_the_selected_root() { | ||
| 251 | let root = tempfile::tempdir().unwrap(); | ||
| 252 | fixture(root.path()); | ||
| 253 | let mut source = Local::open(root.path()).unwrap(); | ||
| 254 | for path in [ | ||
| 255 | "../one.one", | ||
| 256 | "/one.one", | ||
| 257 | "x/../one.one", | ||
| 258 | "one.one\0", | ||
| 259 | "x\\one.one", | ||
| 260 | ] { | ||
| 261 | assert_eq!( | ||
| 262 | source.read(path, 100).unwrap_err().kind(), | ||
| 263 | io::ErrorKind::InvalidInput | ||
| 264 | ); | ||
| 265 | } | ||
| 266 | #[cfg(unix)] | ||
| 267 | { | ||
| 268 | let other = tempfile::tempdir().unwrap(); | ||
| 269 | fs::write(other.path().join("other.one"), b"outside").unwrap(); | ||
| 270 | std::os::unix::fs::symlink(other.path().join("other.one"), root.path().join("link.one")) | ||
| 271 | .unwrap(); | ||
| 272 | assert_eq!( | ||
| 273 | source.read("link.one", 100).unwrap_err().kind(), | ||
| 274 | io::ErrorKind::PermissionDenied | ||
| 275 | ); | ||
| 276 | assert!( | ||
| 277 | matches!(discover(&mut source, limits()), Err(Error::Entry { path }) if path == "link.one") | ||
| 278 | ); | ||
| 279 | } | ||
| 280 | } | ||
crates/notebook/tests/external_assets.rs created+162| ... | @@ -0,0 +1,162 @@ | ||
| 1 | use notebook::discover::{Error, Local, read_external_asset}; | ||
| 2 | use std::{fs, io}; | ||
| 3 | |||
| 4 | #[test] | ||
| 5 | fn nested_external_payloads_are_exact_and_empty_is_distinct_from_missing() { | ||
| 6 | let root = tempfile::tempdir().unwrap(); | ||
| 7 | let directory = root.path().join("Group 🦀/Section é_onefiles"); | ||
| 8 | fs::create_dir_all(&directory).unwrap(); | ||
| 9 | let filename = "2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin"; | ||
| 10 | let mut source = Local::open(root.path()).unwrap(); | ||
| 11 | let section = "Group 🦀/Section é.ONE"; | ||
| 12 | assert!( | ||
| 13 | matches!(read_external_asset(&mut source, section, filename, 0), | ||
| 14 | Err(Error::Io { error, .. }) if error.kind() == io::ErrorKind::NotFound) | ||
| 15 | ); | ||
| 16 | fs::write(directory.join(filename), []).unwrap(); | ||
| 17 | assert!( | ||
| 18 | read_external_asset(&mut source, section, filename, 0) | ||
| 19 | .unwrap() | ||
| 20 | .is_empty() | ||
| 21 | ); | ||
| 22 | let bytes: Vec<_> = (0..65537).map(|index| (index % 251) as u8).collect(); | ||
| 23 | fs::write(directory.join(filename), &bytes).unwrap(); | ||
| 24 | assert!( | ||
| 25 | matches!(read_external_asset(&mut source, section, filename, bytes.len()-1), | ||
| 26 | Err(Error::Io { error, .. }) if error.kind() == io::ErrorKind::FileTooLarge) | ||
| 27 | ); | ||
| 28 | assert_eq!( | ||
| 29 | read_external_asset(&mut source, section, filename, bytes.len()).unwrap(), | ||
| 30 | bytes | ||
| 31 | ); | ||
| 32 | assert_eq!(fs::read(directory.join(filename)).unwrap(), bytes); | ||
| 33 | } | ||
| 34 | |||
| 35 | #[test] | ||
| 36 | fn invalid_asset_locations_fail_before_accessing_the_source() { | ||
| 37 | struct Unused; | ||
| 38 | impl notebook::discover::Source for Unused { | ||
| 39 | fn entries(&mut self, _: &str, _: usize) -> io::Result<Vec<notebook::discover::Entry>> { | ||
| 40 | panic!("Unexpected enumeration") | ||
| 41 | } | ||
| 42 | fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> { | ||
| 43 | panic!("Unexpected read") | ||
| 44 | } | ||
| 45 | } | ||
| 46 | let filename = "2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin"; | ||
| 47 | for section in [ | ||
| 48 | "", | ||
| 49 | ".one", | ||
| 50 | "Group/.one", | ||
| 51 | "/Section.one", | ||
| 52 | "../Section.one", | ||
| 53 | "Group/../Section.one", | ||
| 54 | "Group\\Section.one", | ||
| 55 | "Section.onetoc2", | ||
| 56 | ] { | ||
| 57 | assert!(matches!( | ||
| 58 | read_external_asset(&mut Unused, section, filename, 100), | ||
| 59 | Err(Error::Entry { .. }) | ||
| 60 | )); | ||
| 61 | } | ||
| 62 | for name in [ | ||
| 63 | "", | ||
| 64 | "attachment.png", | ||
| 65 | "../2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin", | ||
| 66 | "2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin:other", | ||
| 67 | "<invfdo>", | ||
| 68 | ] { | ||
| 69 | assert!(matches!( | ||
| 70 | read_external_asset(&mut Unused, "Section.one", name, 100), | ||
| 71 | Err(Error::Entry { .. }) | ||
| 72 | )); | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | #[test] | ||
| 77 | fn reserved_payload_directories_are_not_notebook_groups() { | ||
| 78 | let root = tempfile::tempdir().unwrap(); | ||
| 79 | fs::write( | ||
| 80 | root.path().join("Section.ONE"), | ||
| 81 | onestore::create_section("Section.one", "Fixture", "Author").unwrap(), | ||
| 82 | ) | ||
| 83 | .unwrap(); | ||
| 84 | for name in ["section_onefiles", "orphan_onefiles", "ordinary"] { | ||
| 85 | fs::create_dir(root.path().join(name)).unwrap(); | ||
| 86 | } | ||
| 87 | fs::write( | ||
| 88 | root.path().join("section_onefiles/unfinished.onebin"), | ||
| 89 | b"payload", | ||
| 90 | ) | ||
| 91 | .unwrap(); | ||
| 92 | let catalog = notebook::discover::discover( | ||
| 93 | &mut Local::open(root.path()).unwrap(), | ||
| 94 | notebook::discover::Limits { | ||
| 95 | entries: 4, | ||
| 96 | bytes_per_file: 1 << 20, | ||
| 97 | depth: 1, | ||
| 98 | }, | ||
| 99 | ) | ||
| 100 | .unwrap(); | ||
| 101 | assert_eq!(catalog.sections.len(), 1); | ||
| 102 | assert_eq!( | ||
| 103 | catalog | ||
| 104 | .groups | ||
| 105 | .iter() | ||
| 106 | .map(|group| group.path.as_str()) | ||
| 107 | .collect::<Vec<_>>(), | ||
| 108 | ["ordinary"] | ||
| 109 | ); | ||
| 110 | } | ||
| 111 | |||
| 112 | #[test] | ||
| 113 | #[cfg(feature = "smb")] | ||
| 114 | #[ignore = "requires a disposable Samba mirror of corpus/native-external-assets/notebook at ONESTORE_SMB_NOTEBOOK"] | ||
| 115 | fn live_external_payloads() { | ||
| 116 | use onestore::{ | ||
| 117 | FileDataReference, RevisionIndex, Store, | ||
| 118 | document::{Document, Kind}, | ||
| 119 | }; | ||
| 120 | let root = std::path::Path::new("../../corpus/native-external-assets/notebook"); | ||
| 121 | let bytes = fs::read(root.join("synthetic.one")).unwrap(); | ||
| 122 | let store = Store::parse(&bytes).unwrap(); | ||
| 123 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 124 | let document = Document::parse(&index).unwrap(); | ||
| 125 | let client = notebook::smb::Client::connect( | ||
| 126 | &std::env::var("ONESTORE_SMB_LAB").unwrap(), | ||
| 127 | "agent", | ||
| 128 | notebook::smb::Credentials::default(), | ||
| 129 | std::time::Duration::from_secs(5), | ||
| 130 | ) | ||
| 131 | .unwrap(); | ||
| 132 | let mut remote = | ||
| 133 | notebook::discover::Smb::new(&client, &std::env::var("ONESTORE_SMB_NOTEBOOK").unwrap()) | ||
| 134 | .unwrap(); | ||
| 135 | let mut local = Local::open(root).unwrap(); | ||
| 136 | let mut seen = std::collections::BTreeSet::new(); | ||
| 137 | for node in document | ||
| 138 | .spaces | ||
| 139 | .values() | ||
| 140 | .flat_map(|space| space.revisions.values()) | ||
| 141 | .flat_map(|revision| revision.nodes.values()) | ||
| 142 | { | ||
| 143 | if let Kind::File { | ||
| 144 | reference: FileDataReference::External(filename), | ||
| 145 | .. | ||
| 146 | } = &node.kind | ||
| 147 | && seen.insert(filename) | ||
| 148 | { | ||
| 149 | let expected = fs::read(root.join("synthetic_onefiles").join(filename)).unwrap(); | ||
| 150 | assert_eq!( | ||
| 151 | read_external_asset(&mut local, "synthetic.one", filename, expected.len()).unwrap(), | ||
| 152 | expected | ||
| 153 | ); | ||
| 154 | assert_eq!( | ||
| 155 | read_external_asset(&mut remote, "synthetic.one", filename, expected.len()) | ||
| 156 | .unwrap(), | ||
| 157 | expected | ||
| 158 | ); | ||
| 159 | } | ||
| 160 | } | ||
| 161 | assert_eq!(seen.len(), 3); | ||
| 162 | } | ||
crates/notebook/tests/support/page_schedule.rs created+254| ... | @@ -0,0 +1,254 @@ | ||
| 1 | use crate::disk; | ||
| 2 | use notebook::{EditStatus, Operation, Remote, Replica}; | ||
| 3 | use onestore::{ | ||
| 4 | CommitError, ExGuid, Insertion, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, | ||
| 5 | document::{Document, Kind}, | ||
| 6 | }; | ||
| 7 | use std::{io, sync::LazyLock}; | ||
| 8 | |||
| 9 | #[path = "../../../onestore/tests/support/current.rs"] | ||
| 10 | mod current; | ||
| 11 | |||
| 12 | static SOURCE: LazyLock<Vec<u8>> = LazyLock::new(|| { | ||
| 13 | let mut source = onestore::create_section("page-schedule.one", "Original", "Author").unwrap(); | ||
| 14 | for _ in 0..3 { | ||
| 15 | let page = onestore::PageCreation::new(None, Some("Same title"), "Author").unwrap(); | ||
| 16 | source = PreparedEdit::create_page(&source, &page) | ||
| 17 | .unwrap() | ||
| 18 | .as_bytes() | ||
| 19 | .to_vec(); | ||
| 20 | } | ||
| 21 | source | ||
| 22 | }); | ||
| 23 | |||
| 24 | fn pages(source: &[u8]) -> Vec<(ExGuid, ExGuid, u32)> { | ||
| 25 | let store = Store::parse(source).unwrap(); | ||
| 26 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 27 | index.validate_current().unwrap(); | ||
| 28 | let document = Document::parse(&index).unwrap(); | ||
| 29 | document | ||
| 30 | .pages() | ||
| 31 | .unwrap() | ||
| 32 | .into_iter() | ||
| 33 | .map(|(sid, page)| { | ||
| 34 | let space = &document.spaces[&sid]; | ||
| 35 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 36 | let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else { | ||
| 37 | panic!() | ||
| 38 | }; | ||
| 39 | (sid, page, level.unwrap_or(1)) | ||
| 40 | }) | ||
| 41 | .collect() | ||
| 42 | } | ||
| 43 | |||
| 44 | struct Session<'a> { | ||
| 45 | disk: &'a mut disk::Disk, | ||
| 46 | operation: Option<&'a Operation>, | ||
| 47 | } | ||
| 48 | |||
| 49 | impl Remote for Session<'_> { | ||
| 50 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 51 | Ok(self.disk.visible.clone()) | ||
| 52 | } | ||
| 53 | |||
| 54 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 55 | let mut expected = pages(&self.disk.visible); | ||
| 56 | match self.operation.unwrap() { | ||
| 57 | Operation::CreatePage(page) => expected.push((page.space(), page.object(), 1)), | ||
| 58 | Operation::Pages(batch) => { | ||
| 59 | for change in &batch.edits { | ||
| 60 | let at = expected.iter().position(|p| p.0 == change.space()).unwrap(); | ||
| 61 | expected[at].2 = change.level(); | ||
| 62 | if let PagePosition::Before(before) = change.position() { | ||
| 63 | let page = expected.remove(at); | ||
| 64 | let at = before.map_or(expected.len(), |before| { | ||
| 65 | expected.iter().position(|p| p.0 == before).unwrap() | ||
| 66 | }); | ||
| 67 | expected.insert(at, page); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | } | ||
| 71 | Operation::Insert(insertion) => { | ||
| 72 | let store = Store::parse(edit.as_bytes()).unwrap(); | ||
| 73 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 74 | let document = Document::parse(&index).unwrap(); | ||
| 75 | let matching: Vec<_> = document | ||
| 76 | .spaces | ||
| 77 | .values() | ||
| 78 | .filter_map(|space| { | ||
| 79 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 80 | view.nodes.get(&insertion.text_object()) | ||
| 81 | }) | ||
| 82 | .collect(); | ||
| 83 | assert_eq!(matching.len(), 1); | ||
| 84 | assert!( | ||
| 85 | matches!(&matching[0].kind, Kind::RichText { text, .. } if text == "Body 🦋 é") | ||
| 86 | ); | ||
| 87 | } | ||
| 88 | _ => panic!(), | ||
| 89 | } | ||
| 90 | assert_eq!(pages(edit.as_bytes()), expected); | ||
| 91 | let old = current::current(&self.disk.durable); | ||
| 92 | let new = current::current(edit.as_bytes()); | ||
| 93 | let result = edit.commit(self.disk); | ||
| 94 | let observed = current::current(&self.disk.durable); | ||
| 95 | assert!(observed == old || observed == new); | ||
| 96 | if result.is_ok() { | ||
| 97 | assert_eq!(observed, new); | ||
| 98 | } | ||
| 99 | result | ||
| 100 | } | ||
| 101 | |||
| 102 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 103 | onestore::confirm_snapshot(self.disk, snapshot) | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | pub fn run(input: &[u8]) { | ||
| 108 | let directory = tempfile::tempdir().unwrap(); | ||
| 109 | let mut replicas: [Option<Replica>; 12] = std::array::from_fn(|_| None); | ||
| 110 | let mut owned: [Vec<(ExGuid, ExGuid)>; 12] = std::array::from_fn(|_| Vec::new()); | ||
| 111 | let mut disk = disk::Disk { | ||
| 112 | visible: SOURCE.clone(), | ||
| 113 | durable: SOURCE.clone(), | ||
| 114 | operation: 0, | ||
| 115 | fail_at: None, | ||
| 116 | write_limit: 4096, | ||
| 117 | random: 1, | ||
| 118 | }; | ||
| 119 | for step in input.chunks_exact(8).take(48) { | ||
| 120 | let actor = usize::from(step[0]) % replicas.len(); | ||
| 121 | let path = directory.path().join(format!("{actor}.sqlite")); | ||
| 122 | let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, &SOURCE).unwrap()); | ||
| 123 | let snapshot = cache.snapshot().unwrap(); | ||
| 124 | let pending = cache.pending().unwrap(); | ||
| 125 | match step[1] % 8 { | ||
| 126 | 0 | 1 => { | ||
| 127 | let title = (step[2] & 1 != 0).then_some("Same 🦋 é"); | ||
| 128 | let page = onestore::PageCreation::new(None, title, "Author").unwrap(); | ||
| 129 | cache.create_page(&snapshot, &page).unwrap().unwrap(); | ||
| 130 | owned[actor].push((page.space(), page.object())); | ||
| 131 | if step[1] % 8 == 1 { | ||
| 132 | let insertion = | ||
| 133 | Insertion::outline(page.object(), 36.0, 36.0, "Body 🦋 é", "Author") | ||
| 134 | .unwrap(); | ||
| 135 | cache | ||
| 136 | .insert(&cache.snapshot().unwrap(), page.space(), &insertion) | ||
| 137 | .unwrap() | ||
| 138 | .unwrap(); | ||
| 139 | } | ||
| 140 | } | ||
| 141 | 2 => { | ||
| 142 | let statuses: Vec<_> = pending | ||
| 143 | .iter() | ||
| 144 | .map(|edit| cache.status(edit.id).unwrap()) | ||
| 145 | .collect(); | ||
| 146 | replicas[actor] = None; | ||
| 147 | let cache = Replica::open(&path).unwrap(); | ||
| 148 | assert!(cache.snapshot().unwrap() == snapshot); | ||
| 149 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 150 | assert_eq!( | ||
| 151 | pending | ||
| 152 | .iter() | ||
| 153 | .map(|edit| cache.status(edit.id).unwrap()) | ||
| 154 | .collect::<Vec<_>>(), | ||
| 155 | statuses | ||
| 156 | ); | ||
| 157 | replicas[actor] = Some(cache); | ||
| 158 | } | ||
| 159 | 3 | 4 => { | ||
| 160 | disk.operation = 0; | ||
| 161 | disk.write_limit = if step[3] & 1 == 0 { 17 } else { 4096 }; | ||
| 162 | disk.fail_at = | ||
| 163 | (step[2] != 0).then_some(usize::from(u16::from_le_bytes([step[2], step[3]]))); | ||
| 164 | disk.random = u64::from(step[4]) + 1; | ||
| 165 | let prior = pending | ||
| 166 | .first() | ||
| 167 | .and_then(|edit| cache.status(edit.id).unwrap()); | ||
| 168 | let mut session = Session { | ||
| 169 | disk: &mut disk, | ||
| 170 | operation: pending.first().map(|edit| &edit.operation), | ||
| 171 | }; | ||
| 172 | let result = cache.sync_once(&mut session); | ||
| 173 | if matches!(prior, Some(EditStatus::AwaitingConfirmation { .. })) { | ||
| 174 | assert!( | ||
| 175 | result.is_err() | ||
| 176 | || !matches!(result.unwrap(), Some((_, EditStatus::Conflict(_)))) | ||
| 177 | ); | ||
| 178 | } | ||
| 179 | } | ||
| 180 | 5 => { | ||
| 181 | disk.visible.clone_from(&disk.durable); | ||
| 182 | disk.fail_at = None; | ||
| 183 | } | ||
| 184 | 6 => { | ||
| 185 | let order = pages(&snapshot); | ||
| 186 | let count = 1 + usize::from(step[3] & 1 != 0); | ||
| 187 | let selected: Vec<_> = (0..count) | ||
| 188 | .map(|i| order[(usize::from(step[2]) + i) % order.len()].0) | ||
| 189 | .collect(); | ||
| 190 | let anchor = (step[4] & 1 != 0) | ||
| 191 | .then_some(order[usize::from(step[5]) % order.len()].0) | ||
| 192 | .filter(|sid| !selected.contains(sid)); | ||
| 193 | let edits: Vec<_> = selected | ||
| 194 | .iter() | ||
| 195 | .enumerate() | ||
| 196 | .map(|(i, sid)| { | ||
| 197 | let level = u32::from(step[6 + i]) % 3 + 1; | ||
| 198 | if step[4] & 2 == 0 { | ||
| 199 | PageEdit::set_level(*sid, level).unwrap() | ||
| 200 | } else { | ||
| 201 | PageEdit::move_to(*sid, anchor, level).unwrap() | ||
| 202 | } | ||
| 203 | }) | ||
| 204 | .collect(); | ||
| 205 | let expected = PreparedEdit::pages(&snapshot, &edits); | ||
| 206 | match cache.pages(&snapshot, &edits) { | ||
| 207 | Ok(id) => { | ||
| 208 | let expected = expected.unwrap(); | ||
| 209 | assert_eq!( | ||
| 210 | current::current(&cache.snapshot().unwrap()), | ||
| 211 | current::current(expected.as_bytes()) | ||
| 212 | ); | ||
| 213 | assert_eq!(id.is_some(), expected.as_bytes() != snapshot); | ||
| 214 | } | ||
| 215 | Err(_) => { | ||
| 216 | assert!(expected.is_err()); | ||
| 217 | assert_eq!(cache.snapshot().unwrap(), snapshot); | ||
| 218 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 219 | } | ||
| 220 | } | ||
| 221 | } | ||
| 222 | 7 => { | ||
| 223 | if let Some(edit) = pending.first() | ||
| 224 | && matches!( | ||
| 225 | cache.status(edit.id).unwrap(), | ||
| 226 | Some(EditStatus::Conflict(_)) | ||
| 227 | ) | ||
| 228 | && let Operation::Pages(batch) = &edit.operation | ||
| 229 | { | ||
| 230 | let remote = cache.remote_snapshot().unwrap(); | ||
| 231 | let order = pages(&remote); | ||
| 232 | let anchor = (step[2] & 1 != 0) | ||
| 233 | .then_some(order[usize::from(step[3]) % order.len()].0) | ||
| 234 | .filter(|sid| batch.edits.iter().all(|e| e.space() != *sid)); | ||
| 235 | let edits: Vec<_> = batch | ||
| 236 | .edits | ||
| 237 | .iter() | ||
| 238 | .map(|edit| edit.reposition(PagePosition::Before(anchor), 1).unwrap()) | ||
| 239 | .collect(); | ||
| 240 | let result = cache.rebase_pages_conflict(edit.id, &snapshot, &remote, &edits); | ||
| 241 | assert_eq!(cache.snapshot().unwrap(), snapshot); | ||
| 242 | if result.is_err() { | ||
| 243 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 244 | } | ||
| 245 | } | ||
| 246 | } | ||
| 247 | _ => unreachable!(), | ||
| 248 | } | ||
| 249 | let local = pages(&replicas[actor].as_ref().unwrap().snapshot().unwrap()); | ||
| 250 | for page in &owned[actor] { | ||
| 251 | assert!(local.iter().any(|(sid, oid, _)| (*sid, *oid) == *page)); | ||
| 252 | } | ||
| 253 | } | ||
| 254 | } | ||
crates/notebook/tests/support/tree_schedule.rs created+271| ... | @@ -0,0 +1,271 @@ | ||
| 1 | use crate::disk; | ||
| 2 | use notebook::{EditStatus, Operation, Remote, Replica}; | ||
| 3 | use onestore::{ | ||
| 4 | CommitError, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, TreeEdit, | ||
| 5 | document::{Document, Kind}, | ||
| 6 | }; | ||
| 7 | use std::{io, sync::LazyLock}; | ||
| 8 | |||
| 9 | static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid)> = LazyLock::new(|| { | ||
| 10 | let mut source = | ||
| 11 | onestore::create_section("tree-schedule.one", "Original 🦀 é 0", "Author").unwrap(); | ||
| 12 | let store = Store::parse(&source).unwrap(); | ||
| 13 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 14 | let document = Document::parse(&index).unwrap(); | ||
| 15 | let (sid, page) = document.pages().unwrap()[0]; | ||
| 16 | let space = &document.spaces[&sid]; | ||
| 17 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 18 | let outline = *view.nodes[&page] | ||
| 19 | .children | ||
| 20 | .iter() | ||
| 21 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 22 | .unwrap(); | ||
| 23 | for at in 1..12 { | ||
| 24 | let insert = | ||
| 25 | Insertion::paragraph(outline, None, &format!("Original 🦀 é {at}"), "Author").unwrap(); | ||
| 26 | source = PreparedEdit::insert(&source, sid, &insert) | ||
| 27 | .unwrap() | ||
| 28 | .as_bytes() | ||
| 29 | .to_vec(); | ||
| 30 | } | ||
| 31 | (source, sid, outline) | ||
| 32 | }); | ||
| 33 | |||
| 34 | fn rows(source: &[u8]) -> Vec<(ExGuid, ExGuid, String)> { | ||
| 35 | let (_, sid, outline) = &*SOURCE; | ||
| 36 | let store = Store::parse(source).unwrap(); | ||
| 37 | assert!(store.checksum_mismatches.is_empty()); | ||
| 38 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 39 | index.validate_current().unwrap(); | ||
| 40 | let document = Document::parse(&index).unwrap(); | ||
| 41 | let space = &document.spaces[sid]; | ||
| 42 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 43 | let (_, page) = document | ||
| 44 | .pages() | ||
| 45 | .unwrap() | ||
| 46 | .into_iter() | ||
| 47 | .find(|(space, _)| space == sid) | ||
| 48 | .unwrap(); | ||
| 49 | if !view.nodes[&page].children.contains(outline) { | ||
| 50 | return Vec::new(); | ||
| 51 | } | ||
| 52 | let node = &view.nodes[outline]; | ||
| 53 | let mut rows = Vec::new(); | ||
| 54 | for paragraph in &node.children { | ||
| 55 | let node = &view.nodes[paragraph]; | ||
| 56 | assert!(node.children.is_empty()); | ||
| 57 | let [text] = node.content.as_slice() else { | ||
| 58 | panic!() | ||
| 59 | }; | ||
| 60 | let Kind::RichText { text: value, .. } = &view.nodes[text].kind else { | ||
| 61 | panic!() | ||
| 62 | }; | ||
| 63 | rows.push((*paragraph, *text, value.clone())); | ||
| 64 | } | ||
| 65 | rows | ||
| 66 | } | ||
| 67 | |||
| 68 | struct Session<'a> { | ||
| 69 | disk: &'a mut disk::Disk, | ||
| 70 | operation: Option<&'a Operation>, | ||
| 71 | } | ||
| 72 | |||
| 73 | impl Remote for Session<'_> { | ||
| 74 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 75 | Ok(self.disk.visible.clone()) | ||
| 76 | } | ||
| 77 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 78 | let before = rows(&self.disk.visible); | ||
| 79 | let mut expected = before.clone(); | ||
| 80 | match self.operation.unwrap() { | ||
| 81 | Operation::Tree(edit) => { | ||
| 82 | let at = expected | ||
| 83 | .iter() | ||
| 84 | .position(|(id, _, _)| *id == edit.intent.object()) | ||
| 85 | .unwrap(); | ||
| 86 | let row = expected.remove(at); | ||
| 87 | if let Some((parent, anchor)) = edit.intent.destination() { | ||
| 88 | assert_eq!(parent, SOURCE.2); | ||
| 89 | let at = anchor | ||
| 90 | .map(|anchor| { | ||
| 91 | expected | ||
| 92 | .iter() | ||
| 93 | .position(|(id, _, _)| *id == anchor) | ||
| 94 | .unwrap() | ||
| 95 | }) | ||
| 96 | .unwrap_or(expected.len()); | ||
| 97 | expected.insert(at, row); | ||
| 98 | } | ||
| 99 | } | ||
| 100 | Operation::Text(edit) => { | ||
| 101 | assert_eq!(edit.range, 0..1); | ||
| 102 | let (_, _, text) = expected | ||
| 103 | .iter_mut() | ||
| 104 | .find(|(_, id, _)| *id == edit.object) | ||
| 105 | .unwrap(); | ||
| 106 | text.replace_range(0..1, &edit.replacement); | ||
| 107 | } | ||
| 108 | Operation::Format(format) => { | ||
| 109 | let [onestore::TextAttribute::FontSize(size)] = format.attributes.as_slice() else { | ||
| 110 | panic!() | ||
| 111 | }; | ||
| 112 | let store = Store::parse(edit.as_bytes()).unwrap(); | ||
| 113 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 114 | let document = Document::parse(&index).unwrap(); | ||
| 115 | let space = &document.spaces[&SOURCE.1]; | ||
| 116 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 117 | assert_eq!( | ||
| 118 | view.text_runs(format.object).unwrap()[0].format.font_size, | ||
| 119 | Some(*size) | ||
| 120 | ); | ||
| 121 | } | ||
| 122 | _ => panic!(), | ||
| 123 | } | ||
| 124 | assert_eq!(rows(edit.as_bytes()), expected); | ||
| 125 | let result = edit.commit(self.disk); | ||
| 126 | let durable = rows(&self.disk.durable); | ||
| 127 | assert!(durable == before || durable == expected); | ||
| 128 | if result.is_ok() { | ||
| 129 | assert_eq!(durable, expected); | ||
| 130 | } | ||
| 131 | result | ||
| 132 | } | ||
| 133 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 134 | onestore::confirm_snapshot(self.disk, snapshot) | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | pub fn run(input: &[u8]) { | ||
| 139 | let (source, sid, outline) = &*SOURCE; | ||
| 140 | let directory = tempfile::tempdir().unwrap(); | ||
| 141 | let mut replicas: [Option<Replica>; 12] = std::array::from_fn(|_| None); | ||
| 142 | let mut disk = disk::Disk { | ||
| 143 | visible: source.clone(), | ||
| 144 | durable: source.clone(), | ||
| 145 | operation: 0, | ||
| 146 | fail_at: None, | ||
| 147 | write_limit: 4096, | ||
| 148 | random: 1, | ||
| 149 | }; | ||
| 150 | for step in input.chunks_exact(8).take(48) { | ||
| 151 | let actor = usize::from(step[0]) % replicas.len(); | ||
| 152 | let path = directory.path().join(format!("{actor}.sqlite")); | ||
| 153 | let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap()); | ||
| 154 | let snapshot = cache.snapshot().unwrap(); | ||
| 155 | let pending = cache.pending().unwrap(); | ||
| 156 | let local = rows(&snapshot); | ||
| 157 | match step[1] % 8 { | ||
| 158 | 0..=2 if !local.is_empty() => { | ||
| 159 | let target = usize::from(step[2]) % local.len(); | ||
| 160 | let id = match step[1] % 8 { | ||
| 161 | 0 => { | ||
| 162 | let before = | ||
| 163 | (step[3] & 1 == 0).then(|| local[usize::from(step[4]) % local.len()].0); | ||
| 164 | cache | ||
| 165 | .tree( | ||
| 166 | &snapshot, | ||
| 167 | *sid, | ||
| 168 | &TreeEdit::move_to(local[target].0, *outline, before, "Offline") | ||
| 169 | .unwrap(), | ||
| 170 | ) | ||
| 171 | .unwrap() | ||
| 172 | } | ||
| 173 | 1 => cache | ||
| 174 | .tree( | ||
| 175 | &snapshot, | ||
| 176 | *sid, | ||
| 177 | &TreeEdit::delete(local[target].0, "Offline").unwrap(), | ||
| 178 | ) | ||
| 179 | .unwrap(), | ||
| 180 | _ if step[3] & 1 == 0 => cache | ||
| 181 | .edit_text( | ||
| 182 | &snapshot, | ||
| 183 | *sid, | ||
| 184 | local[target].1, | ||
| 185 | 0..1, | ||
| 186 | &char::from(b'A' + step[3] % 26).to_string(), | ||
| 187 | ) | ||
| 188 | .unwrap(), | ||
| 189 | _ => cache | ||
| 190 | .format( | ||
| 191 | &snapshot, | ||
| 192 | *sid, | ||
| 193 | local[target].1, | ||
| 194 | 0..1, | ||
| 195 | &[onestore::TextAttribute::FontSize( | ||
| 196 | 12.0 + f32::from(step[3] % 20), | ||
| 197 | )], | ||
| 198 | ) | ||
| 199 | .unwrap(), | ||
| 200 | }; | ||
| 201 | let next = cache.pending().unwrap(); | ||
| 202 | assert_eq!(next[..pending.len()], pending); | ||
| 203 | assert_eq!(next.len(), pending.len() + usize::from(id.is_some())); | ||
| 204 | } | ||
| 205 | 3 | 4 => { | ||
| 206 | disk.operation = 0; | ||
| 207 | disk.fail_at = (step[4] != 0) | ||
| 208 | .then_some(usize::from(u16::from_le_bytes([step[4], step[5]])) % 2048 + 1); | ||
| 209 | disk.write_limit = if step[6] & 1 == 0 { 17 } else { 4096 }; | ||
| 210 | disk.random = u64::from(step[7]) + 1; | ||
| 211 | let _ = cache.sync_once(&mut Session { | ||
| 212 | disk: &mut disk, | ||
| 213 | operation: pending.first().map(|p| &p.operation), | ||
| 214 | }); | ||
| 215 | let next = cache.pending().unwrap(); | ||
| 216 | if next.len() == pending.len() { | ||
| 217 | assert_eq!(next, pending); | ||
| 218 | } else { | ||
| 219 | assert_eq!(next, pending[1..]); | ||
| 220 | let Some(EditStatus::Published { revision }) = | ||
| 221 | cache.status(pending[0].id).unwrap() | ||
| 222 | else { | ||
| 223 | panic!() | ||
| 224 | }; | ||
| 225 | let store = Store::parse(&disk.durable).unwrap(); | ||
| 226 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 227 | assert!(index.spaces[sid].revisions.contains_key(&revision)); | ||
| 228 | } | ||
| 229 | if !next.is_empty() { | ||
| 230 | assert!(cache.snapshot().unwrap() == snapshot); | ||
| 231 | } | ||
| 232 | disk.visible.clone_from(&disk.durable); | ||
| 233 | } | ||
| 234 | 5 => { | ||
| 235 | let mut expected = rows(&disk.visible); | ||
| 236 | if !expected.is_empty() { | ||
| 237 | let target = usize::from(step[2]) % expected.len(); | ||
| 238 | let (_, text, content) = &mut expected[target]; | ||
| 239 | let at = u32::try_from(content.encode_utf16().count()).unwrap(); | ||
| 240 | content.push('R'); | ||
| 241 | let snapshot = disk.visible.clone(); | ||
| 242 | let edit = PreparedEdit::text(&snapshot, *sid, *text, at..at, "R").unwrap(); | ||
| 243 | disk.fail_at = None; | ||
| 244 | edit.commit(&mut disk).unwrap(); | ||
| 245 | assert_eq!(rows(&disk.durable), expected); | ||
| 246 | } | ||
| 247 | } | ||
| 248 | _ => { | ||
| 249 | if step[1] % 8 == 6 | ||
| 250 | && let Some(first) = pending.first() | ||
| 251 | && matches!(first.operation, Operation::Tree(_)) | ||
| 252 | && matches!( | ||
| 253 | cache.status(first.id).unwrap(), | ||
| 254 | Some(EditStatus::Conflict(_)) | ||
| 255 | ) | ||
| 256 | { | ||
| 257 | let remote = cache.remote_snapshot().unwrap(); | ||
| 258 | let _ = cache.rebase_tree_conflict(first.id, &snapshot, &remote); | ||
| 259 | } | ||
| 260 | let pending = cache.pending().unwrap(); | ||
| 261 | drop(replicas[actor].take()); | ||
| 262 | let cache = Replica::open(&path).unwrap(); | ||
| 263 | assert!(cache.snapshot().unwrap() == snapshot); | ||
| 264 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 265 | replicas[actor] = Some(cache); | ||
| 266 | } | ||
| 267 | } | ||
| 268 | rows(&disk.durable); | ||
| 269 | rows(&replicas[actor].as_ref().unwrap().snapshot().unwrap()); | ||
| 270 | } | ||
| 271 | } | ||
crates/notebook/tests/sync.rs created+3931| ... | @@ -0,0 +1,3931 @@ | ||
| 1 | use notebook::{ConflictKind, EditStatus, Error, Remote, Replica}; | ||
| 2 | use onestore::{ | ||
| 3 | CommitError, CommitIo, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, | ||
| 4 | document::{Document, Kind}, | ||
| 5 | }; | ||
| 6 | use std::io; | ||
| 7 | |||
| 8 | #[path = "../../onestore/tests/support/disk.rs"] | ||
| 9 | mod disk; | ||
| 10 | #[path = "sync/outline.rs"] | ||
| 11 | mod outline; | ||
| 12 | #[path = "sync/page.rs"] | ||
| 13 | mod page; | ||
| 14 | #[path = "support/page_schedule.rs"] | ||
| 15 | mod page_schedule; | ||
| 16 | #[path = "sync/pages.rs"] | ||
| 17 | mod pages; | ||
| 18 | #[path = "sync/tree.rs"] | ||
| 19 | mod tree; | ||
| 20 | #[path = "support/tree_schedule.rs"] | ||
| 21 | mod tree_schedule; | ||
| 22 | |||
| 23 | mod paragraph { | ||
| 24 | use super::*; | ||
| 25 | use onestore::{Insertion, ParagraphJoin, ParagraphSplit, TextAttribute}; | ||
| 26 | |||
| 27 | fn fixture() -> (Vec<u8>, ExGuid, ExGuid, ExGuid) { | ||
| 28 | let source = onestore::create_section("paragraph.one", "ab🦀cd", "Fixture").unwrap(); | ||
| 29 | let (sid, left, _) = text(&source); | ||
| 30 | let store = Store::parse(&source).unwrap(); | ||
| 31 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 32 | let doc = Document::parse(&index).unwrap(); | ||
| 33 | let space = &doc.spaces[&sid]; | ||
| 34 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 35 | let parent = *view | ||
| 36 | .nodes | ||
| 37 | .iter() | ||
| 38 | .find(|(_, node)| node.content == [left]) | ||
| 39 | .unwrap() | ||
| 40 | .0; | ||
| 41 | (source, sid, left, parent) | ||
| 42 | } | ||
| 43 | |||
| 44 | fn native_reconciliation(keyboard: bool) -> (Vec<u8>, Vec<serde_json::Value>) { | ||
| 45 | let source = include_bytes!( | ||
| 46 | "../../../corpus/paragraph-edit/reconciliation/before/notebook/synthetic.one" | ||
| 47 | ); | ||
| 48 | let native: &[u8] = if keyboard { | ||
| 49 | include_bytes!( | ||
| 50 | "../../../corpus/paragraph-edit/reconciliation/keyboard/notebook/synthetic.one" | ||
| 51 | ) | ||
| 52 | } else { | ||
| 53 | include_bytes!( | ||
| 54 | "../../../corpus/paragraph-edit/reconciliation/remote/notebook/synthetic.one" | ||
| 55 | ) | ||
| 56 | }; | ||
| 57 | let store = Store::parse(source).unwrap(); | ||
| 58 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 59 | let document = Document::parse(&index).unwrap(); | ||
| 60 | let mut server = Server::new(native); | ||
| 61 | let mut recorded = Vec::new(); | ||
| 62 | let mut counts = [0; 3]; | ||
| 63 | for (sid, page) in document.pages().unwrap() { | ||
| 64 | let space = &document.spaces[&sid]; | ||
| 65 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 66 | let Kind::Metadata { | ||
| 67 | title: Some(name), .. | ||
| 68 | } = &view.nodes[&view.roots[&2]].kind | ||
| 69 | else { | ||
| 70 | continue; | ||
| 71 | }; | ||
| 72 | if !name.starts_with("Split ") && !name.starts_with("Join ") { | ||
| 73 | continue; | ||
| 74 | } | ||
| 75 | let outline = view.nodes[&page] | ||
| 76 | .children | ||
| 77 | .iter() | ||
| 78 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 79 | .unwrap(); | ||
| 80 | let children = &view.nodes[outline].children; | ||
| 81 | let left = view.nodes[&children[0]].content[0]; | ||
| 82 | let split = name.starts_with("Split "); | ||
| 83 | let adoption = name == "Join empty adoption"; | ||
| 84 | let directory = tempfile::tempdir().unwrap(); | ||
| 85 | let path = directory.path().join("native-reconciliation.sqlite"); | ||
| 86 | let mut cache = Replica::create(&path, source).unwrap(); | ||
| 87 | let (id, target, intent) = if split { | ||
| 88 | let intent = ParagraphSplit::new(left, 2, "Offline author").unwrap(); | ||
| 89 | ( | ||
| 90 | cache.split(source, sid, &intent).unwrap().unwrap(), | ||
| 91 | intent.text_object(), | ||
| 92 | serde_json::to_value(intent).unwrap(), | ||
| 93 | ) | ||
| 94 | } else { | ||
| 95 | let right = view.nodes[&children[1]].content[0]; | ||
| 96 | let intent = ParagraphJoin::new(left, right, "Offline author").unwrap(); | ||
| 97 | ( | ||
| 98 | cache.join(source, sid, &intent).unwrap().unwrap(), | ||
| 99 | if adoption { right } else { left }, | ||
| 100 | serde_json::to_value(intent).unwrap(), | ||
| 101 | ) | ||
| 102 | }; | ||
| 103 | drop(cache); | ||
| 104 | cache = Replica::open(&path).unwrap(); | ||
| 105 | let at = if split { | ||
| 106 | 2 | ||
| 107 | } else if adoption { | ||
| 108 | 1 | ||
| 109 | } else { | ||
| 110 | 4 | ||
| 111 | }; | ||
| 112 | let dependent = cache | ||
| 113 | .edit_text( | ||
| 114 | &cache.snapshot().unwrap(), | ||
| 115 | sid, | ||
| 116 | target, | ||
| 117 | at..at + 1, | ||
| 118 | if adoption { "I" } else { "C" }, | ||
| 119 | ) | ||
| 120 | .unwrap() | ||
| 121 | .unwrap(); | ||
| 122 | let local = cache.snapshot().unwrap(); | ||
| 123 | let pending = cache.pending().unwrap(); | ||
| 124 | drop(cache); | ||
| 125 | cache = Replica::open(&path).unwrap(); | ||
| 126 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 127 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 128 | let previous = server.publications; | ||
| 129 | let remote = server.visible.clone(); | ||
| 130 | let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 131 | assert_eq!(actual, id); | ||
| 132 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 133 | if !keyboard { | ||
| 134 | counts[2] += 1; | ||
| 135 | assert_eq!( | ||
| 136 | status, | ||
| 137 | EditStatus::Conflict(ConflictKind::TargetUnavailable), | ||
| 138 | "{name}" | ||
| 139 | ); | ||
| 140 | assert_eq!(server.publications, previous); | ||
| 141 | assert_eq!(server.visible, remote); | ||
| 142 | drop(cache); | ||
| 143 | cache = Replica::open(&path).unwrap(); | ||
| 144 | assert_eq!(cache.status(id).unwrap(), Some(status)); | ||
| 145 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 146 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 147 | continue; | ||
| 148 | } | ||
| 149 | let automatic = ["prefix", "format", "sibling"] | ||
| 150 | .iter() | ||
| 151 | .any(|suffix| name.ends_with(suffix)); | ||
| 152 | if automatic { | ||
| 153 | counts[0] += 1; | ||
| 154 | assert!( | ||
| 155 | matches!(status, EditStatus::Published { .. }), | ||
| 156 | "{name}: {status:?}" | ||
| 157 | ); | ||
| 158 | } else { | ||
| 159 | let expected = if name.ends_with("boundary") || adoption { | ||
| 160 | ConflictKind::TextChanged | ||
| 161 | } else { | ||
| 162 | ConflictKind::StructureChanged | ||
| 163 | }; | ||
| 164 | assert_eq!(status, EditStatus::Conflict(expected), "{name}"); | ||
| 165 | assert_eq!(server.publications, previous); | ||
| 166 | assert_eq!(server.visible, remote); | ||
| 167 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 168 | drop(cache); | ||
| 169 | cache = Replica::open(&path).unwrap(); | ||
| 170 | assert_eq!(cache.status(id).unwrap(), Some(status)); | ||
| 171 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 172 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 173 | if adoption || name == "Join remote list" { | ||
| 174 | counts[2] += 1; | ||
| 175 | assert!(cache.rebase_join_conflict(id, &local, &remote).is_err()); | ||
| 176 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 177 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 178 | assert_eq!(cache.status(id).unwrap(), Some(status)); | ||
| 179 | recorded.push(serde_json::json!({"case": name, "space": sid, "intent": intent, "outcome": "retained_conflict"})); | ||
| 180 | continue; | ||
| 181 | } | ||
| 182 | counts[1] += 1; | ||
| 183 | if split { | ||
| 184 | let offset = if name.ends_with("boundary") { 3 } else { 2 }; | ||
| 185 | cache | ||
| 186 | .rebase_conflict(id, &local, &remote, offset..offset) | ||
| 187 | .unwrap(); | ||
| 188 | let notebook::Operation::Split(edit) = &cache.pending().unwrap()[0].operation | ||
| 189 | else { | ||
| 190 | panic!() | ||
| 191 | }; | ||
| 192 | assert_eq!( | ||
| 193 | edit.intent, | ||
| 194 | serde_json::from_value::<ParagraphSplit>(intent.clone()) | ||
| 195 | .unwrap() | ||
| 196 | .reposition(offset) | ||
| 197 | ); | ||
| 198 | } else { | ||
| 199 | cache.rebase_join_conflict(id, &local, &remote).unwrap(); | ||
| 200 | } | ||
| 201 | drop(cache); | ||
| 202 | cache = Replica::open(&path).unwrap(); | ||
| 203 | let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 204 | assert_eq!(actual, id); | ||
| 205 | assert!( | ||
| 206 | matches!(status, EditStatus::Published { .. }), | ||
| 207 | "{name}: {status:?}" | ||
| 208 | ); | ||
| 209 | } | ||
| 210 | drop(cache); | ||
| 211 | cache = Replica::open(&path).unwrap(); | ||
| 212 | let first_receipt = cache.status(id).unwrap().unwrap(); | ||
| 213 | let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 214 | assert_eq!(actual, dependent); | ||
| 215 | assert!( | ||
| 216 | matches!(status, EditStatus::Published { .. }), | ||
| 217 | "{name} dependent: {status:?}" | ||
| 218 | ); | ||
| 219 | assert_eq!(server.publications, previous + 2); | ||
| 220 | drop(cache); | ||
| 221 | cache = Replica::open(&path).unwrap(); | ||
| 222 | assert_eq!(cache.status(id).unwrap(), Some(first_receipt)); | ||
| 223 | assert_eq!(cache.status(dependent).unwrap(), Some(status)); | ||
| 224 | assert!(cache.pending().unwrap().is_empty()); | ||
| 225 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 226 | let EditStatus::Published { revision } = first_receipt else { | ||
| 227 | panic!() | ||
| 228 | }; | ||
| 229 | let EditStatus::Published { | ||
| 230 | revision: dependent_revision, | ||
| 231 | } = status | ||
| 232 | else { | ||
| 233 | panic!() | ||
| 234 | }; | ||
| 235 | recorded.push(serde_json::json!({"case": name, "space": sid, "intent": intent, | ||
| 236 | "outcome": if automatic { "automatic" } else { "reviewed" }, "receipt": revision, "dependent_receipt": dependent_revision})); | ||
| 237 | } | ||
| 238 | assert_eq!(counts, if keyboard { [6, 8, 2] } else { [0, 0, 16] }); | ||
| 239 | assert_eq!(server.publications, if keyboard { 28 } else { 0 }); | ||
| 240 | (server.durable, recorded) | ||
| 241 | } | ||
| 242 | |||
| 243 | #[test] | ||
| 244 | fn native_com_replacement_preserves_every_local_paragraph_branch() { | ||
| 245 | native_reconciliation(false); | ||
| 246 | } | ||
| 247 | |||
| 248 | #[test] | ||
| 249 | fn native_changes_commute_or_retain_reviewable_paragraph_dependencies() { | ||
| 250 | native_reconciliation(true); | ||
| 251 | } | ||
| 252 | |||
| 253 | #[test] | ||
| 254 | #[ignore = "exports native-controlled reconciliation for cold OneNote validation"] | ||
| 255 | fn export_native_reconciliation() { | ||
| 256 | let output = std::path::PathBuf::from( | ||
| 257 | std::env::var_os("ONESTORE_NATIVE_RECONCILIATION_OUTPUT").unwrap(), | ||
| 258 | ); | ||
| 259 | assert!(output.is_absolute()); | ||
| 260 | std::fs::create_dir(&output).unwrap(); | ||
| 261 | let (bytes, cases) = native_reconciliation(true); | ||
| 262 | std::fs::create_dir(output.join("candidate")).unwrap(); | ||
| 263 | std::fs::write(output.join("candidate/synthetic.one"), bytes).unwrap(); | ||
| 264 | std::fs::write( | ||
| 265 | output.join("manifest.json"), | ||
| 266 | serde_json::to_vec_pretty(&cases).unwrap(), | ||
| 267 | ) | ||
| 268 | .unwrap(); | ||
| 269 | } | ||
| 270 | |||
| 271 | #[test] | ||
| 272 | fn native_splits_retain_independent_local_identities_and_changed_boundaries() { | ||
| 273 | let source = include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"); | ||
| 274 | let native = include_bytes!("../../../corpus/paragraph-edit/split/notebook/synthetic.one"); | ||
| 275 | let manifest: serde_json::Value = | ||
| 276 | serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")) | ||
| 277 | .unwrap(); | ||
| 278 | let store = Store::parse(source).unwrap(); | ||
| 279 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 280 | let document = Document::parse(&index).unwrap(); | ||
| 281 | let mut accepted = 0; | ||
| 282 | let mut conflicts = 0; | ||
| 283 | for case in manifest["cases"].as_array().unwrap() { | ||
| 284 | if case["case"] == "Split before hyperlink" { | ||
| 285 | continue; | ||
| 286 | } | ||
| 287 | let left: ExGuid = serde_json::from_value(case["original_text"].clone()).unwrap(); | ||
| 288 | let native_right: ExGuid = serde_json::from_value(case["new_text"].clone()).unwrap(); | ||
| 289 | let (sid, _) = document | ||
| 290 | .pages() | ||
| 291 | .unwrap() | ||
| 292 | .into_iter() | ||
| 293 | .find(|(sid, _)| { | ||
| 294 | let space = &document.spaces[sid]; | ||
| 295 | space.revisions[&space.contexts[&ExGuid::default()]] | ||
| 296 | .nodes | ||
| 297 | .contains_key(&left) | ||
| 298 | }) | ||
| 299 | .unwrap(); | ||
| 300 | let split = ParagraphSplit::new( | ||
| 301 | left, | ||
| 302 | u32::try_from(case["offset_utf16"].as_u64().unwrap()).unwrap(), | ||
| 303 | "Offline author", | ||
| 304 | ) | ||
| 305 | .unwrap(); | ||
| 306 | let directory = tempfile::tempdir().unwrap(); | ||
| 307 | let path = directory.path().join("native-split.sqlite"); | ||
| 308 | let mut cache = Replica::create(&path, source).unwrap(); | ||
| 309 | let id = cache.split(source, sid, &split).unwrap().unwrap(); | ||
| 310 | let current = cache.snapshot().unwrap(); | ||
| 311 | let dependent = cache | ||
| 312 | .edit_text( | ||
| 313 | &current, | ||
| 314 | sid, | ||
| 315 | split.text_object(), | ||
| 316 | 0..0, | ||
| 317 | "Local dependent edit: ", | ||
| 318 | ) | ||
| 319 | .unwrap() | ||
| 320 | .unwrap(); | ||
| 321 | let local = cache.snapshot().unwrap(); | ||
| 322 | let pending = cache.pending().unwrap(); | ||
| 323 | let mut server = Server::new(native); | ||
| 324 | let (published, status) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 325 | assert_eq!(published, id); | ||
| 326 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 327 | if matches!(case["case"].as_str().unwrap(), "Split end" | "Split empty") { | ||
| 328 | accepted += 1; | ||
| 329 | assert!( | ||
| 330 | matches!(status, EditStatus::Published { .. }), | ||
| 331 | "{}: {status:?}", | ||
| 332 | case["case"] | ||
| 333 | ); | ||
| 334 | drop(cache); | ||
| 335 | cache = Replica::open(&path).unwrap(); | ||
| 336 | assert_eq!(cache.sync_once(&mut server).unwrap().unwrap().0, dependent); | ||
| 337 | assert_eq!(server.publications, 2); | ||
| 338 | let store = Store::parse(&server.visible).unwrap(); | ||
| 339 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 340 | let document = Document::parse(&index).unwrap(); | ||
| 341 | let space = &document.spaces[&sid]; | ||
| 342 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 343 | let original: ExGuid = | ||
| 344 | serde_json::from_value(case["original_paragraph"].clone()).unwrap(); | ||
| 345 | let native_paragraph: ExGuid = | ||
| 346 | serde_json::from_value(case["new_paragraph"].clone()).unwrap(); | ||
| 347 | let parent = view | ||
| 348 | .nodes | ||
| 349 | .values() | ||
| 350 | .find(|node| node.children.contains(&original)) | ||
| 351 | .unwrap(); | ||
| 352 | let position = parent | ||
| 353 | .children | ||
| 354 | .iter() | ||
| 355 | .position(|id| *id == original) | ||
| 356 | .unwrap(); | ||
| 357 | assert_eq!( | ||
| 358 | &parent.children[position..position + 3], | ||
| 359 | &[original, split.object(), native_paragraph] | ||
| 360 | ); | ||
| 361 | assert!( | ||
| 362 | matches!(&view.nodes[&native_right].kind, Kind::RichText {text,..} if text.is_empty()) | ||
| 363 | ); | ||
| 364 | assert!( | ||
| 365 | matches!(&view.nodes[&split.text_object()].kind, Kind::RichText {text,..} if text == "Local dependent edit: ") | ||
| 366 | ); | ||
| 367 | } else { | ||
| 368 | conflicts += 1; | ||
| 369 | assert!( | ||
| 370 | matches!( | ||
| 371 | status, | ||
| 372 | EditStatus::Conflict( | ||
| 373 | ConflictKind::TextChanged | ConflictKind::StructureChanged | ||
| 374 | ) | ||
| 375 | ), | ||
| 376 | "{}: {status:?}", | ||
| 377 | case["case"] | ||
| 378 | ); | ||
| 379 | assert_eq!(server.publications, 0); | ||
| 380 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 381 | drop(cache); | ||
| 382 | cache = Replica::open(&path).unwrap(); | ||
| 383 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 384 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 385 | assert_eq!(cache.status(id).unwrap(), Some(status)); | ||
| 386 | } | ||
| 387 | } | ||
| 388 | assert_eq!((accepted, conflicts), (2, 10)); | ||
| 389 | } | ||
| 390 | |||
| 391 | #[test] | ||
| 392 | fn native_joins_do_not_acknowledge_or_discard_an_independent_local_branch() { | ||
| 393 | let source = include_bytes!( | ||
| 394 | "../../../corpus/paragraph-edit/join-tags/before/notebook/synthetic.one" | ||
| 395 | ); | ||
| 396 | let native = include_bytes!( | ||
| 397 | "../../../corpus/paragraph-edit/join-tags/joined/notebook/synthetic.one" | ||
| 398 | ); | ||
| 399 | let store = Store::parse(source).unwrap(); | ||
| 400 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 401 | let document = Document::parse(&index).unwrap(); | ||
| 402 | let mut cases = 0; | ||
| 403 | for (sid, page) in document.pages().unwrap() { | ||
| 404 | let space = &document.spaces[&sid]; | ||
| 405 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 406 | let Kind::Metadata { | ||
| 407 | title: Some(name), .. | ||
| 408 | } = &view.nodes[&view.roots[&2]].kind | ||
| 409 | else { | ||
| 410 | continue; | ||
| 411 | }; | ||
| 412 | if !name.starts_with("Join ") { | ||
| 413 | continue; | ||
| 414 | } | ||
| 415 | cases += 1; | ||
| 416 | let outline = view.nodes[&page] | ||
| 417 | .children | ||
| 418 | .iter() | ||
| 419 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 420 | .unwrap(); | ||
| 421 | let children = &view.nodes[outline].children; | ||
| 422 | let left = view.nodes[&children[0]].content[0]; | ||
| 423 | let right = view.nodes[&children[1]].content[0]; | ||
| 424 | let Kind::RichText { | ||
| 425 | text: left_text, .. | ||
| 426 | } = &view.nodes[&left].kind | ||
| 427 | else { | ||
| 428 | panic!() | ||
| 429 | }; | ||
| 430 | let survivor = if left_text.is_empty() { right } else { left }; | ||
| 431 | let directory = tempfile::tempdir().unwrap(); | ||
| 432 | let path = directory.path().join("native-join.sqlite"); | ||
| 433 | let mut cache = Replica::create(&path, source).unwrap(); | ||
| 434 | let join = ParagraphJoin::new(left, right, "Offline author").unwrap(); | ||
| 435 | let id = cache.join(source, sid, &join).unwrap().unwrap(); | ||
| 436 | let current = cache.snapshot().unwrap(); | ||
| 437 | cache | ||
| 438 | .edit_text(&current, sid, survivor, 0..0, "Local dependent edit: ") | ||
| 439 | .unwrap() | ||
| 440 | .unwrap(); | ||
| 441 | let local = cache.snapshot().unwrap(); | ||
| 442 | let pending = cache.pending().unwrap(); | ||
| 443 | let mut server = Server::new(native); | ||
| 444 | let (conflicted, status) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 445 | assert_eq!(conflicted, id); | ||
| 446 | assert!( | ||
| 447 | matches!( | ||
| 448 | status, | ||
| 449 | EditStatus::Conflict(ConflictKind::TargetUnavailable) | ||
| 450 | ), | ||
| 451 | "{name}: {status:?}" | ||
| 452 | ); | ||
| 453 | assert_eq!(server.publications, 0); | ||
| 454 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 455 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 456 | drop(cache); | ||
| 457 | cache = Replica::open(&path).unwrap(); | ||
| 458 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 459 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 460 | assert_eq!(cache.status(id).unwrap(), Some(status)); | ||
| 461 | } | ||
| 462 | assert_eq!(cases, 3); | ||
| 463 | } | ||
| 464 | |||
| 465 | #[test] | ||
| 466 | fn split_join_dependencies_reopen_and_rebase_with_remote_text_and_styles() { | ||
| 467 | let (source, sid, left, parent) = fixture(); | ||
| 468 | let directory = tempfile::tempdir().unwrap(); | ||
| 469 | let path = directory.path().join("paragraph.sqlite"); | ||
| 470 | let mut cache = Replica::create(&path, &source).unwrap(); | ||
| 471 | let split = ParagraphSplit::new(left, 2, "Local").unwrap(); | ||
| 472 | let child = Insertion::paragraph(split.object(), None, "Child", "Local").unwrap(); | ||
| 473 | let join = ParagraphJoin::new(left, split.text_object(), "Local").unwrap(); | ||
| 474 | let mut ids = Vec::new(); | ||
| 475 | for step in 0..6 { | ||
| 476 | let current = cache.snapshot().unwrap(); | ||
| 477 | let id = match step { | ||
| 478 | 0 => cache.split(&current, sid, &split), | ||
| 479 | 1 => cache.edit_text(&current, sid, split.text_object(), 0..2, "🦋"), | ||
| 480 | 2 => cache.format( | ||
| 481 | &current, | ||
| 482 | sid, | ||
| 483 | split.text_object(), | ||
| 484 | 2..3, | ||
| 485 | &[TextAttribute::Bold(true)], | ||
| 486 | ), | ||
| 487 | 3 => cache.insert(&current, sid, &child), | ||
| 488 | 4 => cache.join(&current, sid, &join), | ||
| 489 | 5 => cache.edit_text(&current, sid, left, 5..6, "D"), | ||
| 490 | _ => unreachable!(), | ||
| 491 | } | ||
| 492 | .unwrap() | ||
| 493 | .unwrap(); | ||
| 494 | ids.push(id); | ||
| 495 | let saved = cache.snapshot().unwrap(); | ||
| 496 | let pending = cache.pending().unwrap(); | ||
| 497 | drop(cache); | ||
| 498 | cache = Replica::open(&path).unwrap(); | ||
| 499 | assert_eq!(cache.snapshot().unwrap(), saved); | ||
| 500 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 501 | } | ||
| 502 | let remote = onestore::replace_text(&source, sid, left, 0..0, "Z").unwrap(); | ||
| 503 | let remote = | ||
| 504 | PreparedEdit::format(&remote, sid, left, 1..2, &[TextAttribute::Italic(true)]).unwrap(); | ||
| 505 | let mut server = Server::new(remote.as_bytes()); | ||
| 506 | let local = cache.snapshot().unwrap(); | ||
| 507 | for (i, id) in ids.iter().enumerate() { | ||
| 508 | let (published, state) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 509 | assert_eq!(published, *id); | ||
| 510 | assert!(matches!(state, EditStatus::Published { .. }), "{state:?}"); | ||
| 511 | drop(cache); | ||
| 512 | cache = Replica::open(&path).unwrap(); | ||
| 513 | assert_eq!(cache.status(*id).unwrap(), Some(state)); | ||
| 514 | if i != ids.len() - 1 { | ||
| 515 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 516 | } | ||
| 517 | } | ||
| 518 | assert_eq!(server.publications, ids.len()); | ||
| 519 | assert_eq!(server.visible, server.durable); | ||
| 520 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 521 | let store = Store::parse(&server.durable).unwrap(); | ||
| 522 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 523 | index.validate_current().unwrap(); | ||
| 524 | let doc = Document::parse(&index).unwrap(); | ||
| 525 | let space = &doc.spaces[&sid]; | ||
| 526 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 527 | assert!( | ||
| 528 | matches!(&view.nodes[&left].kind, Kind::RichText { text, .. } if text == "Zab🦋cD") | ||
| 529 | ); | ||
| 530 | assert_eq!(view.nodes[&parent].children, [child.object()]); | ||
| 531 | assert_eq!(view.nodes[&parent].content, [left]); | ||
| 532 | let runs = view.text_runs(left).unwrap(); | ||
| 533 | let chars: Vec<_> = runs | ||
| 534 | .iter() | ||
| 535 | .flat_map(|run| { | ||
| 536 | run.text | ||
| 537 | .chars() | ||
| 538 | .map(|c| (c, run.format.bold, run.format.italic)) | ||
| 539 | }) | ||
| 540 | .collect(); | ||
| 541 | assert_eq!(chars.iter().find(|v| v.0 == 'a').unwrap().2, Some(true)); | ||
| 542 | assert_eq!(chars.iter().find(|v| v.0 == 'c').unwrap().1, Some(true)); | ||
| 543 | } | ||
| 544 | |||
| 545 | #[test] | ||
| 546 | fn changed_split_children_require_fresh_review_without_reallocating_dependents() { | ||
| 547 | let (source, sid, left, parent) = fixture(); | ||
| 548 | let directory = tempfile::tempdir().unwrap(); | ||
| 549 | let path = directory.path().join("paragraph.sqlite"); | ||
| 550 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 551 | let split = ParagraphSplit::new(left, 2, "Local").unwrap(); | ||
| 552 | let id = cache.split(&source, sid, &split).unwrap().unwrap(); | ||
| 553 | let dependent = cache | ||
| 554 | .edit_text( | ||
| 555 | &cache.snapshot().unwrap(), | ||
| 556 | sid, | ||
| 557 | split.text_object(), | ||
| 558 | 0..2, | ||
| 559 | "🦋", | ||
| 560 | ) | ||
| 561 | .unwrap() | ||
| 562 | .unwrap(); | ||
| 563 | let local = cache.snapshot().unwrap(); | ||
| 564 | let child = Insertion::paragraph(parent, None, "Remote child", "Remote").unwrap(); | ||
| 565 | let remote = PreparedEdit::insert(&source, sid, &child).unwrap(); | ||
| 566 | let mut server = Server::new(remote.as_bytes()); | ||
| 567 | assert_eq!( | ||
| 568 | cache.sync_once(&mut server).unwrap(), | ||
| 569 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | ||
| 570 | ); | ||
| 571 | assert_eq!(server.publications, 0); | ||
| 572 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 573 | assert!( | ||
| 574 | cache | ||
| 575 | .rebase_conflict(id, &source, &server.visible, 2..2) | ||
| 576 | .is_err() | ||
| 577 | ); | ||
| 578 | assert!( | ||
| 579 | cache | ||
| 580 | .rebase_conflict(id, &local, &server.visible, 2..3) | ||
| 581 | .is_err() | ||
| 582 | ); | ||
| 583 | cache | ||
| 584 | .rebase_conflict(id, &local, &server.visible, 2..2) | ||
| 585 | .unwrap(); | ||
| 586 | let pending = cache.pending().unwrap(); | ||
| 587 | let notebook::Operation::Split(edit) = &pending[0].operation else { | ||
| 588 | panic!() | ||
| 589 | }; | ||
| 590 | assert_eq!(edit.intent, split); | ||
| 591 | assert_eq!(pending[1].id, dependent); | ||
| 592 | drop(cache); | ||
| 593 | let cache = Replica::open(&path).unwrap(); | ||
| 594 | for expected in [id, dependent] { | ||
| 595 | let (actual, state) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 596 | assert_eq!(actual, expected); | ||
| 597 | assert!(matches!(state, EditStatus::Published { .. })); | ||
| 598 | } | ||
| 599 | let store = Store::parse(&server.durable).unwrap(); | ||
| 600 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 601 | let doc = Document::parse(&index).unwrap(); | ||
| 602 | let space = &doc.spaces[&sid]; | ||
| 603 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 604 | assert_eq!(view.nodes[&split.object()].children, [child.object()]); | ||
| 605 | assert!( | ||
| 606 | matches!(&view.nodes[&split.text_object()].kind, Kind::RichText { text, .. } if text == "🦋cd") | ||
| 607 | ); | ||
| 608 | } | ||
| 609 | |||
| 610 | #[test] | ||
| 611 | fn uncertain_paragraph_operations_keep_the_original_attempt_across_reopen() { | ||
| 612 | for join in [false, true] { | ||
| 613 | for fault in [ | ||
| 614 | Fault::Before, | ||
| 615 | Fault::UnknownBefore, | ||
| 616 | Fault::UnknownAfter, | ||
| 617 | Fault::Committed, | ||
| 618 | ] { | ||
| 619 | let (source, sid, left, _) = fixture(); | ||
| 620 | let split = ParagraphSplit::new(left, 2, "Local").unwrap(); | ||
| 621 | let source = if join { | ||
| 622 | PreparedEdit::split(&source, sid, &split) | ||
| 623 | .unwrap() | ||
| 624 | .as_bytes() | ||
| 625 | .to_vec() | ||
| 626 | } else { | ||
| 627 | source | ||
| 628 | }; | ||
| 629 | let directory = tempfile::tempdir().unwrap(); | ||
| 630 | let path = directory.path().join("paragraph.sqlite"); | ||
| 631 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 632 | let id = if join { | ||
| 633 | cache.join( | ||
| 634 | &source, | ||
| 635 | sid, | ||
| 636 | &ParagraphJoin::new(left, split.text_object(), "Local").unwrap(), | ||
| 637 | ) | ||
| 638 | } else { | ||
| 639 | cache.split(&source, sid, &split) | ||
| 640 | } | ||
| 641 | .unwrap() | ||
| 642 | .unwrap(); | ||
| 643 | let local = cache.snapshot().unwrap(); | ||
| 644 | let intent = cache.pending().unwrap(); | ||
| 645 | let mut server = Server::new(&source); | ||
| 646 | server.fault = fault; | ||
| 647 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 648 | let state = cache.status(id).unwrap().unwrap(); | ||
| 649 | drop(cache); | ||
| 650 | let cache = Replica::open(&path).unwrap(); | ||
| 651 | assert_eq!(cache.status(id).unwrap(), Some(state)); | ||
| 652 | if matches!(fault, Fault::UnknownBefore | Fault::UnknownAfter) { | ||
| 653 | assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); | ||
| 654 | assert_eq!(cache.pending().unwrap(), intent); | ||
| 655 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 656 | if matches!(fault, Fault::UnknownBefore) { | ||
| 657 | assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, state))); | ||
| 658 | assert_eq!(server.publications, 1); | ||
| 659 | continue; | ||
| 660 | } | ||
| 661 | server.fault = Fault::Confirm; | ||
| 662 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 663 | assert_eq!(cache.status(id).unwrap(), Some(state)); | ||
| 664 | let (_, state) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 665 | assert!(matches!(state, EditStatus::Published { .. })); | ||
| 666 | assert_eq!(server.publications, 1); | ||
| 667 | } else if matches!(fault, Fault::Before) { | ||
| 668 | assert_eq!(state, EditStatus::Pending); | ||
| 669 | assert!(matches!( | ||
| 670 | cache.sync_once(&mut server).unwrap().unwrap().1, | ||
| 671 | EditStatus::Published { .. } | ||
| 672 | )); | ||
| 673 | assert_eq!(server.publications, 2); | ||
| 674 | } else { | ||
| 675 | assert!(matches!(state, EditStatus::Published { .. })); | ||
| 676 | assert_eq!(server.publications, 1); | ||
| 677 | } | ||
| 678 | assert!(cache.pending().unwrap().is_empty()); | ||
| 679 | let saved = cache.snapshot().unwrap(); | ||
| 680 | if matches!(fault, Fault::UnknownAfter) { | ||
| 681 | // Confirmation refreshes version metadata after reading the acknowledged snapshot. | ||
| 682 | assert!(saved[..212] == server.durable[..212]); | ||
| 683 | assert!(saved[252..] == server.durable[252..]); | ||
| 684 | assert_eq!( | ||
| 685 | Store::parse(&server.durable).unwrap().header.generation, | ||
| 686 | Store::parse(&saved).unwrap().header.generation + 1 | ||
| 687 | ); | ||
| 688 | } else { | ||
| 689 | assert!(saved == server.durable); | ||
| 690 | } | ||
| 691 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | ||
| 692 | assert!(cache.snapshot().unwrap() == server.durable); | ||
| 693 | } | ||
| 694 | } | ||
| 695 | } | ||
| 696 | |||
| 697 | #[test] | ||
| 698 | fn join_reviews_preserve_survivors_and_require_current_child_placement() { | ||
| 699 | for empty in [false, true] { | ||
| 700 | let (source, sid, left, _) = fixture(); | ||
| 701 | let split = ParagraphSplit::new(left, if empty { 0 } else { 2 }, "Local").unwrap(); | ||
| 702 | let source = PreparedEdit::split(&source, sid, &split) | ||
| 703 | .unwrap() | ||
| 704 | .as_bytes() | ||
| 705 | .to_vec(); | ||
| 706 | let directory = tempfile::tempdir().unwrap(); | ||
| 707 | let path = directory.path().join("join.sqlite"); | ||
| 708 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 709 | let intent = ParagraphJoin::new(left, split.text_object(), "Local").unwrap(); | ||
| 710 | let id = cache.join(&source, sid, &intent).unwrap().unwrap(); | ||
| 711 | let survivor = if empty { split.text_object() } else { left }; | ||
| 712 | let dependent = cache | ||
| 713 | .edit_text(&cache.snapshot().unwrap(), sid, survivor, 0..0, "Local ") | ||
| 714 | .unwrap() | ||
| 715 | .unwrap(); | ||
| 716 | let local = cache.snapshot().unwrap(); | ||
| 717 | let child = | ||
| 718 | Insertion::paragraph(split.object(), None, "Remote child", "Remote").unwrap(); | ||
| 719 | let remote = PreparedEdit::insert(&source, sid, &child) | ||
| 720 | .unwrap() | ||
| 721 | .as_bytes() | ||
| 722 | .to_vec(); | ||
| 723 | let mut server = Server::new(&remote); | ||
| 724 | assert_eq!( | ||
| 725 | cache.sync_once(&mut server).unwrap(), | ||
| 726 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | ||
| 727 | ); | ||
| 728 | assert!(cache.rebase_join_conflict(id, &source, &remote).is_err()); | ||
| 729 | assert_eq!(server.publications, 0); | ||
| 730 | cache.rebase_join_conflict(id, &local, &remote).unwrap(); | ||
| 731 | assert!( | ||
| 732 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | ||
| 733 | ); | ||
| 734 | assert!( | ||
| 735 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == dependent) | ||
| 736 | ); | ||
| 737 | let store = Store::parse(&server.durable).unwrap(); | ||
| 738 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 739 | let doc = Document::parse(&index).unwrap(); | ||
| 740 | let space = &doc.spaces[&sid]; | ||
| 741 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 742 | let parent = view | ||
| 743 | .nodes | ||
| 744 | .values() | ||
| 745 | .find(|node| node.content == [survivor]) | ||
| 746 | .unwrap(); | ||
| 747 | assert_eq!(parent.children, [child.object()]); | ||
| 748 | assert!( | ||
| 749 | matches!(&view.nodes[&survivor].kind, Kind::RichText { text, .. } if text == "Local ab🦀cd") | ||
| 750 | ); | ||
| 751 | } | ||
| 752 | |||
| 753 | let (source, sid, left, _) = fixture(); | ||
| 754 | let split = ParagraphSplit::new(left, 0, "Local").unwrap(); | ||
| 755 | let source = PreparedEdit::split(&source, sid, &split) | ||
| 756 | .unwrap() | ||
| 757 | .as_bytes() | ||
| 758 | .to_vec(); | ||
| 759 | let directory = tempfile::tempdir().unwrap(); | ||
| 760 | let path = directory.path().join("identity.sqlite"); | ||
| 761 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 762 | let intent = ParagraphJoin::new(left, split.text_object(), "Local").unwrap(); | ||
| 763 | let id = cache.join(&source, sid, &intent).unwrap().unwrap(); | ||
| 764 | let local = cache.snapshot().unwrap(); | ||
| 765 | let remote = onestore::replace_text(&source, sid, left, 0..0, "Remote").unwrap(); | ||
| 766 | let mut server = Server::new(&remote); | ||
| 767 | assert_eq!( | ||
| 768 | cache.sync_once(&mut server).unwrap(), | ||
| 769 | Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) | ||
| 770 | ); | ||
| 771 | assert!(cache.rebase_join_conflict(id, &local, &remote).is_err()); | ||
| 772 | assert!(cache.snapshot().unwrap() == local); | ||
| 773 | assert_eq!(server.publications, 0); | ||
| 774 | assert_eq!(cache.pending().unwrap().len(), 1); | ||
| 775 | } | ||
| 776 | |||
| 777 | #[test] | ||
| 778 | fn overlapping_clients_retain_each_local_branch_without_replaying_removed_targets() { | ||
| 779 | let (source, sid, left, _) = fixture(); | ||
| 780 | let split = ParagraphSplit::new(left, 2, "Seed").unwrap(); | ||
| 781 | let source = PreparedEdit::split(&source, sid, &split) | ||
| 782 | .unwrap() | ||
| 783 | .as_bytes() | ||
| 784 | .to_vec(); | ||
| 785 | let directory = tempfile::tempdir().unwrap(); | ||
| 786 | let first = Replica::create(directory.path().join("first.sqlite"), &source).unwrap(); | ||
| 787 | let second_path = directory.path().join("second.sqlite"); | ||
| 788 | let second = Replica::create(&second_path, &source).unwrap(); | ||
| 789 | let join = ParagraphJoin::new(left, split.text_object(), "First").unwrap(); | ||
| 790 | first.join(&source, sid, &join).unwrap(); | ||
| 791 | let right_split = ParagraphSplit::new(split.text_object(), 2, "Second").unwrap(); | ||
| 792 | let id = second.split(&source, sid, &right_split).unwrap().unwrap(); | ||
| 793 | second | ||
| 794 | .edit_text( | ||
| 795 | &second.snapshot().unwrap(), | ||
| 796 | sid, | ||
| 797 | right_split.text_object(), | ||
| 798 | 0..0, | ||
| 799 | "Second ", | ||
| 800 | ) | ||
| 801 | .unwrap(); | ||
| 802 | let local = second.snapshot().unwrap(); | ||
| 803 | let pending = second.pending().unwrap(); | ||
| 804 | let mut server = Server::new(&source); | ||
| 805 | assert!(matches!( | ||
| 806 | first.sync_once(&mut server).unwrap().unwrap().1, | ||
| 807 | EditStatus::Published { .. } | ||
| 808 | )); | ||
| 809 | assert_eq!( | ||
| 810 | second.sync_once(&mut server).unwrap(), | ||
| 811 | Some((id, EditStatus::Conflict(ConflictKind::TargetUnavailable))) | ||
| 812 | ); | ||
| 813 | drop(second); | ||
| 814 | let second = Replica::open(&second_path).unwrap(); | ||
| 815 | assert_eq!(second.pending().unwrap(), pending); | ||
| 816 | assert!(second.snapshot().unwrap() == local); | ||
| 817 | assert_eq!(server.publications, 1); | ||
| 818 | assert_eq!( | ||
| 819 | second.status(id).unwrap(), | ||
| 820 | Some(EditStatus::Conflict(ConflictKind::TargetUnavailable)) | ||
| 821 | ); | ||
| 822 | } | ||
| 823 | |||
| 824 | #[test] | ||
| 825 | #[ignore = "exports reconciled paragraph edits for independent native validation"] | ||
| 826 | fn export_native_offline_paragraphs() { | ||
| 827 | use std::{fs, path::PathBuf}; | ||
| 828 | let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/paragraph-edit"); | ||
| 829 | let output = PathBuf::from(std::env::var_os("ONESTORE_OFFLINE_PARAGRAPH_OUTPUT").unwrap()); | ||
| 830 | assert!(output.is_absolute()); | ||
| 831 | fs::create_dir(&output).unwrap(); | ||
| 832 | for (name, source, manifest, split) in [ | ||
| 833 | ( | ||
| 834 | "splits", | ||
| 835 | "before/notebook/synthetic.one", | ||
| 836 | "rust-split/manifest.json", | ||
| 837 | true, | ||
| 838 | ), | ||
| 839 | ( | ||
| 840 | "joins", | ||
| 841 | "split/notebook/synthetic.one", | ||
| 842 | "rust-join/split/manifest.json", | ||
| 843 | false, | ||
| 844 | ), | ||
| 845 | ( | ||
| 846 | "inheritance", | ||
| 847 | "join-edges/before/notebook/synthetic.one", | ||
| 848 | "rust-join/inheritance/manifest.json", | ||
| 849 | false, | ||
| 850 | ), | ||
| 851 | ( | ||
| 852 | "tags", | ||
| 853 | "join-tags/before/notebook/synthetic.one", | ||
| 854 | "rust-join/tags/manifest.json", | ||
| 855 | false, | ||
| 856 | ), | ||
| 857 | ] { | ||
| 858 | let source = fs::read(root.join(source)).unwrap(); | ||
| 859 | let folder = output.join(name); | ||
| 860 | fs::create_dir(&folder).unwrap(); | ||
| 861 | let path = folder.join("cache.sqlite"); | ||
| 862 | let mut cache = Replica::create(&path, &source).unwrap(); | ||
| 863 | let mut server = Server::new(&source); | ||
| 864 | let manifest: serde_json::Value = | ||
| 865 | serde_json::from_slice(&fs::read(root.join(manifest)).unwrap()).unwrap(); | ||
| 866 | let cases = if split { &manifest["cases"] } else { &manifest }; | ||
| 867 | let mut recorded = Vec::new(); | ||
| 868 | for case in cases | ||
| 869 | .as_array() | ||
| 870 | .unwrap() | ||
| 871 | .iter() | ||
| 872 | .filter(|case| case.get("intent").is_some()) | ||
| 873 | { | ||
| 874 | let local = cache.snapshot().unwrap(); | ||
| 875 | let (text, offset) = if split { | ||
| 876 | serde_json::from_value::<ParagraphSplit>(case["intent"].clone()) | ||
| 877 | .unwrap() | ||
| 878 | .position() | ||
| 879 | } else { | ||
| 880 | ( | ||
| 881 | serde_json::from_value::<ParagraphJoin>(case["intent"].clone()) | ||
| 882 | .unwrap() | ||
| 883 | .texts()[0], | ||
| 884 | 1, | ||
| 885 | ) | ||
| 886 | }; | ||
| 887 | let store = Store::parse(&local).unwrap(); | ||
| 888 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 889 | let doc = Document::parse(&index).unwrap(); | ||
| 890 | let (sid, space) = doc | ||
| 891 | .spaces | ||
| 892 | .iter() | ||
| 893 | .find(|(_, space)| { | ||
| 894 | space.revisions[&space.contexts[&ExGuid::default()]] | ||
| 895 | .nodes | ||
| 896 | .contains_key(&text) | ||
| 897 | }) | ||
| 898 | .unwrap(); | ||
| 899 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 900 | let Kind::RichText { text: content, .. } = &view.nodes[&text].kind else { | ||
| 901 | panic!() | ||
| 902 | }; | ||
| 903 | let remote_prefix = offset > 0 && !content.is_empty(); | ||
| 904 | assert!(!content.contains('☂')); | ||
| 905 | if remote_prefix { | ||
| 906 | server.visible = | ||
| 907 | onestore::replace_text(&server.visible, *sid, text, 0..0, "☂").unwrap(); | ||
| 908 | server.durable.clone_from(&server.visible); | ||
| 909 | } | ||
| 910 | let id = if split { | ||
| 911 | cache.split( | ||
| 912 | &local, | ||
| 913 | *sid, | ||
| 914 | &serde_json::from_value(case["intent"].clone()).unwrap(), | ||
| 915 | ) | ||
| 916 | } else { | ||
| 917 | cache.join( | ||
| 918 | &local, | ||
| 919 | *sid, | ||
| 920 | &serde_json::from_value(case["intent"].clone()).unwrap(), | ||
| 921 | ) | ||
| 922 | } | ||
| 923 | .unwrap() | ||
| 924 | .unwrap(); | ||
| 925 | recorded.push(serde_json::json!({"case": case["case"], "id": id, | ||
| 926 | "space": sid, "intent": case["intent"], "remote_prefix": remote_prefix})); | ||
| 927 | drop(cache); | ||
| 928 | cache = Replica::open(&path).unwrap(); | ||
| 929 | } | ||
| 930 | for case in &mut recorded { | ||
| 931 | let id = case["id"].as_u64().unwrap(); | ||
| 932 | if id % 2 == 0 { | ||
| 933 | server.fault = Fault::UnknownAfter; | ||
| 934 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 935 | assert!(matches!( | ||
| 936 | cache.status(id).unwrap(), | ||
| 937 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 938 | )); | ||
| 939 | drop(cache); | ||
| 940 | cache = Replica::open(&path).unwrap(); | ||
| 941 | } | ||
| 942 | let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 943 | assert_eq!(actual, id); | ||
| 944 | let EditStatus::Published { revision } = status else { | ||
| 945 | panic!("{name}: {status:?}") | ||
| 946 | }; | ||
| 947 | case["revision"] = serde_json::to_value(revision).unwrap(); | ||
| 948 | } | ||
| 949 | assert_eq!(server.publications, recorded.len()); | ||
| 950 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | ||
| 951 | assert!(cache.snapshot().unwrap() == server.durable); | ||
| 952 | cache | ||
| 953 | .export_recovery(folder.join("recovery.sqlite")) | ||
| 954 | .unwrap(); | ||
| 955 | fs::create_dir(folder.join("candidate")).unwrap(); | ||
| 956 | fs::write(folder.join("candidate/synthetic.one"), &server.durable).unwrap(); | ||
| 957 | fs::write( | ||
| 958 | folder.join("manifest.json"), | ||
| 959 | serde_json::to_vec_pretty(&recorded).unwrap(), | ||
| 960 | ) | ||
| 961 | .unwrap(); | ||
| 962 | } | ||
| 963 | } | ||
| 964 | } | ||
| 965 | |||
| 966 | #[derive(Clone, Copy, Default)] | ||
| 967 | enum Fault { | ||
| 968 | #[default] | ||
| 969 | None, | ||
| 970 | Before, | ||
| 971 | UnknownBefore, | ||
| 972 | UnknownAfter, | ||
| 973 | Committed, | ||
| 974 | PanicBefore, | ||
| 975 | PanicAfter, | ||
| 976 | Confirm, | ||
| 977 | ConfirmCommitted, | ||
| 978 | } | ||
| 979 | |||
| 980 | struct Server { | ||
| 981 | visible: Vec<u8>, | ||
| 982 | durable: Vec<u8>, | ||
| 983 | fault: Fault, | ||
| 984 | publications: usize, | ||
| 985 | confirmations: usize, | ||
| 986 | } | ||
| 987 | |||
| 988 | impl Server { | ||
| 989 | fn new(source: &[u8]) -> Self { | ||
| 990 | Self { | ||
| 991 | visible: source.to_vec(), | ||
| 992 | durable: source.to_vec(), | ||
| 993 | fault: Fault::None, | ||
| 994 | publications: 0, | ||
| 995 | confirmations: 0, | ||
| 996 | } | ||
| 997 | } | ||
| 998 | } | ||
| 999 | |||
| 1000 | fn failure(state: CommitState) -> CommitError { | ||
| 1001 | CommitError { | ||
| 1002 | state, | ||
| 1003 | error: io::Error::from(io::ErrorKind::ConnectionAborted), | ||
| 1004 | } | ||
| 1005 | } | ||
| 1006 | |||
| 1007 | impl CommitIo for Server { | ||
| 1008 | fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> { | ||
| 1009 | let offset = usize::try_from(offset).unwrap(); | ||
| 1010 | let size = output.len().min(self.visible.len().saturating_sub(offset)); | ||
| 1011 | if size > 0 { | ||
| 1012 | output[..size].copy_from_slice(&self.visible[offset..offset + size]); | ||
| 1013 | } | ||
| 1014 | Ok(size) | ||
| 1015 | } | ||
| 1016 | fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> { | ||
| 1017 | let offset = usize::try_from(offset).unwrap(); | ||
| 1018 | self.visible | ||
| 1019 | .resize(self.visible.len().max(offset + bytes.len()), 0); | ||
| 1020 | self.visible[offset..offset + bytes.len()].copy_from_slice(bytes); | ||
| 1021 | Ok(bytes.len()) | ||
| 1022 | } | ||
| 1023 | fn flush(&mut self) -> io::Result<()> { | ||
| 1024 | self.durable.clone_from(&self.visible); | ||
| 1025 | Ok(()) | ||
| 1026 | } | ||
| 1027 | } | ||
| 1028 | |||
| 1029 | impl Remote for Server { | ||
| 1030 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 1031 | Ok(self.visible.clone()) | ||
| 1032 | } | ||
| 1033 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 1034 | self.publications += 1; | ||
| 1035 | let fault = std::mem::take(&mut self.fault); | ||
| 1036 | match fault { | ||
| 1037 | Fault::Before => return Err(failure(CommitState::NotCommitted)), | ||
| 1038 | Fault::UnknownBefore => return Err(failure(CommitState::Unknown)), | ||
| 1039 | Fault::PanicBefore => panic!("Terminated before remote I/O"), | ||
| 1040 | _ => {} | ||
| 1041 | } | ||
| 1042 | let old = self.durable.clone(); | ||
| 1043 | edit.commit(self)?; | ||
| 1044 | match fault { | ||
| 1045 | Fault::UnknownAfter => { | ||
| 1046 | self.durable = old; | ||
| 1047 | Err(failure(CommitState::Unknown)) | ||
| 1048 | } | ||
| 1049 | Fault::Committed => Err(failure(CommitState::Committed)), | ||
| 1050 | Fault::PanicAfter => panic!("Terminated after remote publication"), | ||
| 1051 | _ => Ok(()), | ||
| 1052 | } | ||
| 1053 | } | ||
| 1054 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 1055 | self.confirmations += 1; | ||
| 1056 | if matches!(self.fault, Fault::Confirm) { | ||
| 1057 | self.fault = Fault::None; | ||
| 1058 | return Err(failure(CommitState::Unknown)); | ||
| 1059 | } | ||
| 1060 | onestore::confirm_snapshot(self, snapshot)?; | ||
| 1061 | if matches!(self.fault, Fault::ConfirmCommitted) { | ||
| 1062 | self.fault = Fault::None; | ||
| 1063 | return Err(failure(CommitState::Committed)); | ||
| 1064 | } | ||
| 1065 | Ok(()) | ||
| 1066 | } | ||
| 1067 | } | ||
| 1068 | |||
| 1069 | fn text(source: &[u8]) -> (ExGuid, ExGuid, String) { | ||
| 1070 | let store = Store::parse(source).unwrap(); | ||
| 1071 | assert!(store.checksum_mismatches.is_empty()); | ||
| 1072 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 1073 | index.validate_current().unwrap(); | ||
| 1074 | let doc = Document::parse(&index).unwrap(); | ||
| 1075 | doc.spaces | ||
| 1076 | .iter() | ||
| 1077 | .find_map(|(sid, space)| { | ||
| 1078 | space.revisions[&space.contexts[&ExGuid::default()]] | ||
| 1079 | .nodes | ||
| 1080 | .iter() | ||
| 1081 | .find_map(|(oid, node)| match &node.kind { | ||
| 1082 | Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())), | ||
| 1083 | _ => None, | ||
| 1084 | }) | ||
| 1085 | }) | ||
| 1086 | .unwrap() | ||
| 1087 | } | ||
| 1088 | |||
| 1089 | #[test] | ||
| 1090 | fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becoming_a_writer() { | ||
| 1091 | use notebook::{Recovery, RecoverySummary}; | ||
| 1092 | use onestore::{Insertion, TextAttribute}; | ||
| 1093 | |||
| 1094 | let directory = tempfile::tempdir().unwrap(); | ||
| 1095 | let path = directory.path().join("live.sqlite"); | ||
| 1096 | let archive_path = directory.path().join("recovery.sqlite"); | ||
| 1097 | let source = onestore::create_section("recovery.one", "Original", "Fixture").unwrap(); | ||
| 1098 | let (space, object, _) = text(&source); | ||
| 1099 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1100 | let published = cache | ||
| 1101 | .edit_text(&source, space, object, 0..0, "Published ") | ||
| 1102 | .unwrap() | ||
| 1103 | .unwrap(); | ||
| 1104 | let mut server = Server::new(&source); | ||
| 1105 | let (_, receipt) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 1106 | let source = cache.snapshot().unwrap(); | ||
| 1107 | let store = Store::parse(&source).unwrap(); | ||
| 1108 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 1109 | let document = Document::parse(&index).unwrap(); | ||
| 1110 | let (page_space, page) = document.pages().unwrap()[0]; | ||
| 1111 | let outline = Insertion::outline(page, 100.0, 200.0, "Outline", "Fixture").unwrap(); | ||
| 1112 | let inserted = cache | ||
| 1113 | .insert(&source, page_space, &outline) | ||
| 1114 | .unwrap() | ||
| 1115 | .unwrap(); | ||
| 1116 | let paragraph = Insertion::paragraph(outline.object(), None, "Recovery 🦀", "Fixture").unwrap(); | ||
| 1117 | cache | ||
| 1118 | .insert(&cache.snapshot().unwrap(), page_space, &paragraph) | ||
| 1119 | .unwrap(); | ||
| 1120 | cache | ||
| 1121 | .format( | ||
| 1122 | &cache.snapshot().unwrap(), | ||
| 1123 | page_space, | ||
| 1124 | paragraph.text_object(), | ||
| 1125 | 0..3, | ||
| 1126 | &[TextAttribute::Bold(true)], | ||
| 1127 | ) | ||
| 1128 | .unwrap(); | ||
| 1129 | cache | ||
| 1130 | .edit_text( | ||
| 1131 | &cache.snapshot().unwrap(), | ||
| 1132 | page_space, | ||
| 1133 | paragraph.text_object(), | ||
| 1134 | 0..0, | ||
| 1135 | "Pending ", | ||
| 1136 | ) | ||
| 1137 | .unwrap(); | ||
| 1138 | server.fault = Fault::UnknownBefore; | ||
| 1139 | assert!(matches!( | ||
| 1140 | cache.sync_once(&mut server), | ||
| 1141 | Err(Error::Remote(CommitError { | ||
| 1142 | state: CommitState::Unknown, | ||
| 1143 | .. | ||
| 1144 | })) | ||
| 1145 | )); | ||
| 1146 | let working = cache.snapshot().unwrap(); | ||
| 1147 | let remote = cache.remote_snapshot().unwrap(); | ||
| 1148 | let pending = cache.pending().unwrap(); | ||
| 1149 | let uncertain = cache.status(inserted).unwrap(); | ||
| 1150 | let source_file = std::fs::read(&path).unwrap(); | ||
| 1151 | let summary = RecoverySummary { | ||
| 1152 | queued_edits: 4, | ||
| 1153 | conflicts: 0, | ||
| 1154 | uncertain_edits: 1, | ||
| 1155 | published_receipts: 1, | ||
| 1156 | working_bytes: working.len() as u64, | ||
| 1157 | remote_bytes: remote.len() as u64, | ||
| 1158 | cached_assets: 0, | ||
| 1159 | cached_asset_bytes: 0, | ||
| 1160 | }; | ||
| 1161 | assert_eq!(cache.recovery_summary().unwrap(), summary); | ||
| 1162 | cache.export_recovery(&archive_path).unwrap(); | ||
| 1163 | assert_eq!(std::fs::read(&path).unwrap(), source_file); | ||
| 1164 | let archive_bytes = std::fs::read(&archive_path).unwrap(); | ||
| 1165 | assert!(Replica::open(&archive_path).is_err()); | ||
| 1166 | assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes); | ||
| 1167 | assert!(Recovery::open(&path).is_err()); | ||
| 1168 | let archive = Recovery::open(&archive_path).unwrap(); | ||
| 1169 | assert_eq!(archive.summary().unwrap(), summary); | ||
| 1170 | assert_eq!(archive.snapshot().unwrap(), working); | ||
| 1171 | assert_eq!(archive.remote_snapshot().unwrap(), remote); | ||
| 1172 | assert_eq!(archive.pending().unwrap(), pending); | ||
| 1173 | assert_eq!(archive.status(published).unwrap(), Some(receipt)); | ||
| 1174 | assert_eq!(archive.status(inserted).unwrap(), uncertain); | ||
| 1175 | for edit in &pending { | ||
| 1176 | assert_eq!( | ||
| 1177 | archive.status(edit.id).unwrap(), | ||
| 1178 | cache.status(edit.id).unwrap() | ||
| 1179 | ); | ||
| 1180 | } | ||
| 1181 | let EditStatus::Published { revision } = receipt else { | ||
| 1182 | panic!() | ||
| 1183 | }; | ||
| 1184 | assert_eq!(archive.receipts().unwrap(), [(published, revision)].into()); | ||
| 1185 | assert_eq!( | ||
| 1186 | archive.status(u64::MAX - 1).unwrap_err().to_string(), | ||
| 1187 | cache.status(u64::MAX - 1).unwrap_err().to_string() | ||
| 1188 | ); | ||
| 1189 | for existing in [&path, &archive_path] { | ||
| 1190 | assert!( | ||
| 1191 | matches!(cache.export_recovery(existing), Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists) | ||
| 1192 | ); | ||
| 1193 | } | ||
| 1194 | assert_eq!(std::fs::read(&path).unwrap(), source_file); | ||
| 1195 | assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes); | ||
| 1196 | cache | ||
| 1197 | .edit_text(&working, space, object, 0..0, "Later ") | ||
| 1198 | .unwrap(); | ||
| 1199 | assert_eq!(archive.snapshot().unwrap(), working); | ||
| 1200 | assert_eq!(archive.pending().unwrap(), pending); | ||
| 1201 | drop(archive); | ||
| 1202 | assert_eq!( | ||
| 1203 | Recovery::open(&archive_path).unwrap().summary().unwrap(), | ||
| 1204 | summary | ||
| 1205 | ); | ||
| 1206 | assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes); | ||
| 1207 | #[cfg(unix)] | ||
| 1208 | { | ||
| 1209 | use std::os::unix::fs::PermissionsExt; | ||
| 1210 | assert_eq!( | ||
| 1211 | std::fs::metadata(&archive_path) | ||
| 1212 | .unwrap() | ||
| 1213 | .permissions() | ||
| 1214 | .mode() | ||
| 1215 | & 0o777, | ||
| 1216 | 0o600 | ||
| 1217 | ); | ||
| 1218 | } | ||
| 1219 | let remaining: Vec<_> = std::fs::read_dir(directory.path()) | ||
| 1220 | .unwrap() | ||
| 1221 | .map(|entry| entry.unwrap().file_name()) | ||
| 1222 | .collect(); | ||
| 1223 | assert!( | ||
| 1224 | remaining | ||
| 1225 | .iter() | ||
| 1226 | .all(|name| !name.to_string_lossy().starts_with(".onestore-recovery-")), | ||
| 1227 | "Temporary recovery files remain: {remaining:?}" | ||
| 1228 | ); | ||
| 1229 | } | ||
| 1230 | |||
| 1231 | #[test] | ||
| 1232 | fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archives() { | ||
| 1233 | use notebook::Recovery; | ||
| 1234 | |||
| 1235 | let directory = tempfile::tempdir().unwrap(); | ||
| 1236 | let source = onestore::create_section("recovery.one", "Original", "Fixture").unwrap(); | ||
| 1237 | let (space, object, _) = text(&source); | ||
| 1238 | let cache = Replica::create(directory.path().join("live.sqlite"), &source).unwrap(); | ||
| 1239 | let id = cache | ||
| 1240 | .edit_text(&source, space, object, 0..8, "Local") | ||
| 1241 | .unwrap() | ||
| 1242 | .unwrap(); | ||
| 1243 | let changed = onestore::replace_text(&source, space, object, 0..8, "Remote").unwrap(); | ||
| 1244 | let mut server = Server::new(&changed); | ||
| 1245 | let outcome = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 1246 | assert_eq!( | ||
| 1247 | outcome, | ||
| 1248 | (id, EditStatus::Conflict(ConflictKind::TextChanged)) | ||
| 1249 | ); | ||
| 1250 | let path = directory.path().join("conflict.sqlite"); | ||
| 1251 | cache.export_recovery(&path).unwrap(); | ||
| 1252 | let archive = Recovery::open(&path).unwrap(); | ||
| 1253 | assert_eq!(text(&archive.snapshot().unwrap()).2, "Local"); | ||
| 1254 | assert_eq!(archive.remote_snapshot().unwrap(), changed); | ||
| 1255 | assert_eq!(archive.status(id).unwrap(), Some(outcome.1)); | ||
| 1256 | assert_eq!(archive.summary().unwrap().conflicts, 1); | ||
| 1257 | assert_eq!(archive.summary().unwrap().uncertain_edits, 0); | ||
| 1258 | drop(archive); | ||
| 1259 | for sql in [ | ||
| 1260 | "PRAGMA user_version=99", | ||
| 1261 | "PRAGMA user_version=4; PRAGMA application_id=0", | ||
| 1262 | ] { | ||
| 1263 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 1264 | connection.execute_batch(sql).unwrap(); | ||
| 1265 | drop(connection); | ||
| 1266 | let before = std::fs::read(&path).unwrap(); | ||
| 1267 | assert!(Recovery::open(&path).is_err()); | ||
| 1268 | assert_eq!(std::fs::read(&path).unwrap(), before); | ||
| 1269 | } | ||
| 1270 | assert_eq!(cache.status(id).unwrap(), Some(outcome.1)); | ||
| 1271 | assert_eq!(text(&cache.snapshot().unwrap()).2, "Local"); | ||
| 1272 | } | ||
| 1273 | |||
| 1274 | #[test] | ||
| 1275 | fn rebases_multiple_disjoint_remote_changes_and_persists_the_remote_receipt() { | ||
| 1276 | let dir = tempfile::tempdir().unwrap(); | ||
| 1277 | let path = dir.path().join("cache.sqlite"); | ||
| 1278 | let source = onestore::create_section("sync.one", "ab🦀cd", "Fixture").unwrap(); | ||
| 1279 | let (sid, oid, _) = text(&source); | ||
| 1280 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1281 | let id = cache | ||
| 1282 | .edit_text(&source, sid, oid, 2..4, "🐈") | ||
| 1283 | .unwrap() | ||
| 1284 | .unwrap(); | ||
| 1285 | let remote = onestore::replace_text(&source, sid, oid, 0..6, "Xab🦀cYd").unwrap(); | ||
| 1286 | let mut server = Server::new(&remote); | ||
| 1287 | let outcome = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 1288 | assert_eq!(outcome.0, id); | ||
| 1289 | assert!(matches!(outcome.1, EditStatus::Published { .. })); | ||
| 1290 | assert_eq!(text(&server.durable).2, "Xab🐈cYd"); | ||
| 1291 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 1292 | assert!(cache.pending().unwrap().is_empty()); | ||
| 1293 | drop(cache); | ||
| 1294 | let cache = Replica::open(&path).unwrap(); | ||
| 1295 | assert_eq!(cache.status(id).unwrap(), Some(outcome.1)); | ||
| 1296 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | ||
| 1297 | assert_eq!(server.publications, 1); | ||
| 1298 | assert_eq!(cache.status(id + 1).unwrap(), None); | ||
| 1299 | let source = cache.snapshot().unwrap(); | ||
| 1300 | let next = cache | ||
| 1301 | .edit_text(&source, sid, oid, 0..0, "Later ") | ||
| 1302 | .unwrap() | ||
| 1303 | .unwrap(); | ||
| 1304 | assert!(next > id); | ||
| 1305 | } | ||
| 1306 | |||
| 1307 | #[test] | ||
| 1308 | fn overlapping_changes_preserve_both_images_and_survive_restart() { | ||
| 1309 | let dir = tempfile::tempdir().unwrap(); | ||
| 1310 | let path = dir.path().join("cache.sqlite"); | ||
| 1311 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1312 | let (sid, oid, _) = text(&source); | ||
| 1313 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1314 | let id = cache | ||
| 1315 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 1316 | .unwrap() | ||
| 1317 | .unwrap(); | ||
| 1318 | let local = cache.snapshot().unwrap(); | ||
| 1319 | let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); | ||
| 1320 | let mut server = Server::new(&remote); | ||
| 1321 | assert_eq!( | ||
| 1322 | cache.sync_once(&mut server).unwrap(), | ||
| 1323 | Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) | ||
| 1324 | ); | ||
| 1325 | assert_eq!(server.publications, 0); | ||
| 1326 | drop(cache); | ||
| 1327 | let cache = Replica::open(&path).unwrap(); | ||
| 1328 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 1329 | assert_eq!(cache.remote_snapshot().unwrap(), remote); | ||
| 1330 | assert_eq!(cache.pending().unwrap().len(), 1); | ||
| 1331 | assert_eq!( | ||
| 1332 | cache.status(id).unwrap(), | ||
| 1333 | Some(EditStatus::Conflict(ConflictKind::TextChanged)) | ||
| 1334 | ); | ||
| 1335 | } | ||
| 1336 | |||
| 1337 | #[test] | ||
| 1338 | fn lost_replies_and_process_termination_never_blindly_replay_an_attempt() { | ||
| 1339 | for fault in [ | ||
| 1340 | Fault::UnknownBefore, | ||
| 1341 | Fault::UnknownAfter, | ||
| 1342 | Fault::PanicBefore, | ||
| 1343 | Fault::PanicAfter, | ||
| 1344 | ] { | ||
| 1345 | let dir = tempfile::tempdir().unwrap(); | ||
| 1346 | let path = dir.path().join("cache.sqlite"); | ||
| 1347 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1348 | let (sid, oid, _) = text(&source); | ||
| 1349 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1350 | let id = cache | ||
| 1351 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1352 | .unwrap() | ||
| 1353 | .unwrap(); | ||
| 1354 | let mut server = Server::new(&source); | ||
| 1355 | server.fault = fault; | ||
| 1356 | let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | ||
| 1357 | cache.sync_once(&mut server) | ||
| 1358 | })); | ||
| 1359 | let attempted = cache.status(id).unwrap().unwrap(); | ||
| 1360 | assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. })); | ||
| 1361 | drop(cache); | ||
| 1362 | let cache = Replica::open(&path).unwrap(); | ||
| 1363 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 1364 | if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) { | ||
| 1365 | assert!(matches!(result.1, EditStatus::Published { .. })); | ||
| 1366 | assert_eq!(text(&server.durable).2, "Once abc"); | ||
| 1367 | assert_eq!(server.confirmations, 1); | ||
| 1368 | assert!(cache.pending().unwrap().is_empty()); | ||
| 1369 | } else { | ||
| 1370 | assert_eq!(result.1, attempted); | ||
| 1371 | assert_eq!(cache.pending().unwrap().len(), 1); | ||
| 1372 | assert_eq!(server.confirmations, 0); | ||
| 1373 | assert_eq!(server.durable, source); | ||
| 1374 | } | ||
| 1375 | assert_eq!(server.publications, 1); | ||
| 1376 | } | ||
| 1377 | } | ||
| 1378 | |||
| 1379 | #[test] | ||
| 1380 | fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() { | ||
| 1381 | let dir = tempfile::tempdir().unwrap(); | ||
| 1382 | let path = dir.path().join("cache.sqlite"); | ||
| 1383 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1384 | let (sid, oid, _) = text(&source); | ||
| 1385 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1386 | let id = cache | ||
| 1387 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1388 | .unwrap() | ||
| 1389 | .unwrap(); | ||
| 1390 | let mut server = Server::new(&source); | ||
| 1391 | server.fault = Fault::UnknownAfter; | ||
| 1392 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 1393 | server.fault = Fault::Confirm; | ||
| 1394 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 1395 | assert_eq!(server.durable, source); | ||
| 1396 | assert!(matches!( | ||
| 1397 | cache.status(id).unwrap(), | ||
| 1398 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 1399 | )); | ||
| 1400 | assert!(matches!( | ||
| 1401 | cache.sync_once(&mut server).unwrap(), | ||
| 1402 | Some((_, EditStatus::Published { .. })) | ||
| 1403 | )); | ||
| 1404 | assert_eq!(server.publications, 1); | ||
| 1405 | assert_eq!(server.confirmations, 2); | ||
| 1406 | assert_eq!(server.visible, server.durable); | ||
| 1407 | } | ||
| 1408 | |||
| 1409 | #[test] | ||
| 1410 | fn confirmation_cleanup_failure_still_records_a_durable_receipt() { | ||
| 1411 | let dir = tempfile::tempdir().unwrap(); | ||
| 1412 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1413 | let (sid, oid, _) = text(&source); | ||
| 1414 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | ||
| 1415 | let id = cache | ||
| 1416 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1417 | .unwrap() | ||
| 1418 | .unwrap(); | ||
| 1419 | let mut server = Server::new(&source); | ||
| 1420 | server.fault = Fault::UnknownAfter; | ||
| 1421 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 1422 | server.fault = Fault::ConfirmCommitted; | ||
| 1423 | assert!( | ||
| 1424 | matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Committed) | ||
| 1425 | ); | ||
| 1426 | assert!(matches!( | ||
| 1427 | cache.status(id).unwrap(), | ||
| 1428 | Some(EditStatus::Published { .. }) | ||
| 1429 | )); | ||
| 1430 | assert!(cache.pending().unwrap().is_empty()); | ||
| 1431 | assert_eq!(server.visible, server.durable); | ||
| 1432 | assert_eq!(server.publications, 1); | ||
| 1433 | assert_eq!(server.confirmations, 1); | ||
| 1434 | } | ||
| 1435 | |||
| 1436 | #[test] | ||
| 1437 | fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts() { | ||
| 1438 | for fault in [Fault::Before, Fault::Committed] { | ||
| 1439 | let dir = tempfile::tempdir().unwrap(); | ||
| 1440 | let path = dir.path().join("cache.sqlite"); | ||
| 1441 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1442 | let (sid, oid, _) = text(&source); | ||
| 1443 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1444 | let id = cache | ||
| 1445 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1446 | .unwrap() | ||
| 1447 | .unwrap(); | ||
| 1448 | let mut server = Server::new(&source); | ||
| 1449 | server.fault = fault; | ||
| 1450 | assert!(matches!( | ||
| 1451 | cache.sync_once(&mut server), | ||
| 1452 | Err(Error::Remote(_)) | ||
| 1453 | )); | ||
| 1454 | if matches!(fault, Fault::Before) { | ||
| 1455 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 1456 | assert!(matches!( | ||
| 1457 | cache.sync_once(&mut server).unwrap(), | ||
| 1458 | Some((_, EditStatus::Published { .. })) | ||
| 1459 | )); | ||
| 1460 | assert_eq!(server.publications, 2); | ||
| 1461 | } else { | ||
| 1462 | assert!(matches!( | ||
| 1463 | cache.status(id).unwrap(), | ||
| 1464 | Some(EditStatus::Published { .. }) | ||
| 1465 | )); | ||
| 1466 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | ||
| 1467 | assert_eq!(server.publications, 1); | ||
| 1468 | } | ||
| 1469 | assert_eq!(text(&server.durable).2, "Once abc"); | ||
| 1470 | } | ||
| 1471 | } | ||
| 1472 | |||
| 1473 | #[test] | ||
| 1474 | fn database_failures_before_and_after_publication_preserve_recovery_state() { | ||
| 1475 | for table in ["attempt", "receipts"] { | ||
| 1476 | let dir = tempfile::tempdir().unwrap(); | ||
| 1477 | let path = dir.path().join("cache.sqlite"); | ||
| 1478 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1479 | let (sid, oid, _) = text(&source); | ||
| 1480 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1481 | let id = cache | ||
| 1482 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1483 | .unwrap() | ||
| 1484 | .unwrap(); | ||
| 1485 | drop(cache); | ||
| 1486 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 1487 | db.execute_batch(&format!("CREATE TRIGGER interrupted BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT,'Injected cache failure'); END;")).unwrap(); | ||
| 1488 | drop(db); | ||
| 1489 | let cache = Replica::open(&path).unwrap(); | ||
| 1490 | let mut server = Server::new(&source); | ||
| 1491 | assert!(matches!( | ||
| 1492 | cache.sync_once(&mut server), | ||
| 1493 | Err(Error::Database(_)) | ||
| 1494 | )); | ||
| 1495 | assert_eq!(server.publications, usize::from(table == "receipts")); | ||
| 1496 | assert_eq!(cache.pending().unwrap().len(), 1); | ||
| 1497 | drop(cache); | ||
| 1498 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 1499 | db.execute_batch("DROP TRIGGER interrupted").unwrap(); | ||
| 1500 | drop(db); | ||
| 1501 | let cache = Replica::open(&path).unwrap(); | ||
| 1502 | assert!(matches!( | ||
| 1503 | cache.sync_once(&mut server).unwrap(), | ||
| 1504 | Some((_, EditStatus::Published { .. })) | ||
| 1505 | )); | ||
| 1506 | assert!(matches!( | ||
| 1507 | cache.status(id).unwrap(), | ||
| 1508 | Some(EditStatus::Published { .. }) | ||
| 1509 | )); | ||
| 1510 | assert_eq!(server.publications, 1); | ||
| 1511 | assert_eq!(server.confirmations, usize::from(table == "receipts")); | ||
| 1512 | assert_eq!(text(&server.durable).2, "Once abc"); | ||
| 1513 | } | ||
| 1514 | } | ||
| 1515 | |||
| 1516 | #[test] | ||
| 1517 | fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmation() { | ||
| 1518 | struct Paused { | ||
| 1519 | server: Server, | ||
| 1520 | phase: &'static str, | ||
| 1521 | entered: std::sync::mpsc::Sender<()>, | ||
| 1522 | resume: std::sync::mpsc::Receiver<()>, | ||
| 1523 | } | ||
| 1524 | impl Paused { | ||
| 1525 | fn wait(&self, phase: &str) { | ||
| 1526 | if self.phase == phase { | ||
| 1527 | self.entered.send(()).unwrap(); | ||
| 1528 | self.resume | ||
| 1529 | .recv_timeout(std::time::Duration::from_secs(5)) | ||
| 1530 | .unwrap(); | ||
| 1531 | } | ||
| 1532 | } | ||
| 1533 | } | ||
| 1534 | impl Remote for Paused { | ||
| 1535 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 1536 | self.wait("read"); | ||
| 1537 | self.server.read() | ||
| 1538 | } | ||
| 1539 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 1540 | self.wait("publish"); | ||
| 1541 | self.server.publish(edit) | ||
| 1542 | } | ||
| 1543 | fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { | ||
| 1544 | self.wait("confirm"); | ||
| 1545 | self.server.confirm(source) | ||
| 1546 | } | ||
| 1547 | } | ||
| 1548 | for phase in ["read", "publish", "confirm"] { | ||
| 1549 | let dir = tempfile::tempdir().unwrap(); | ||
| 1550 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1551 | let (sid, oid, _) = text(&source); | ||
| 1552 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | ||
| 1553 | let first = cache | ||
| 1554 | .edit_text(&source, sid, oid, 0..0, "First ") | ||
| 1555 | .unwrap() | ||
| 1556 | .unwrap(); | ||
| 1557 | let mut server = Server::new(&source); | ||
| 1558 | if phase == "confirm" { | ||
| 1559 | server.fault = Fault::UnknownAfter; | ||
| 1560 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 1561 | } | ||
| 1562 | let (entered_tx, entered_rx) = std::sync::mpsc::channel(); | ||
| 1563 | let (resume_tx, resume_rx) = std::sync::mpsc::channel(); | ||
| 1564 | let mut paused = Paused { | ||
| 1565 | server, | ||
| 1566 | phase, | ||
| 1567 | entered: entered_tx, | ||
| 1568 | resume: resume_rx, | ||
| 1569 | }; | ||
| 1570 | let mut server = std::thread::scope(|scope| { | ||
| 1571 | let running = scope.spawn(|| { | ||
| 1572 | let result = cache.sync_once(&mut paused); | ||
| 1573 | assert!( | ||
| 1574 | matches!(result, Ok(Some((id, EditStatus::Published { .. }))) if id == first) | ||
| 1575 | ); | ||
| 1576 | paused.server | ||
| 1577 | }); | ||
| 1578 | entered_rx | ||
| 1579 | .recv_timeout(std::time::Duration::from_secs(5)) | ||
| 1580 | .unwrap(); | ||
| 1581 | assert!( | ||
| 1582 | matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock) | ||
| 1583 | ); | ||
| 1584 | assert!( | ||
| 1585 | matches!(cache.rebase_conflict(first, &[], &[], 0..0), Err(Error::Io(error)) if error.kind() == io::ErrorKind::WouldBlock) | ||
| 1586 | ); | ||
| 1587 | let started = std::time::Instant::now(); | ||
| 1588 | let handles: Vec<_> = (0..12) | ||
| 1589 | .map(|writer| { | ||
| 1590 | let cache = &cache; | ||
| 1591 | scope.spawn(move || { | ||
| 1592 | loop { | ||
| 1593 | let snapshot = cache.snapshot().unwrap(); | ||
| 1594 | match cache.edit_text( | ||
| 1595 | &snapshot, | ||
| 1596 | sid, | ||
| 1597 | oid, | ||
| 1598 | 0..0, | ||
| 1599 | &format!("[{writer}] "), | ||
| 1600 | ) { | ||
| 1601 | Ok(Some(id)) => break id, | ||
| 1602 | Err(Error::Io(error)) | ||
| 1603 | if error.kind() == io::ErrorKind::ResourceBusy => {} | ||
| 1604 | other => panic!("Unexpected local outcome {other:?}"), | ||
| 1605 | } | ||
| 1606 | } | ||
| 1607 | }) | ||
| 1608 | }) | ||
| 1609 | .collect(); | ||
| 1610 | let ids: std::collections::BTreeSet<_> = handles | ||
| 1611 | .into_iter() | ||
| 1612 | .map(|handle| handle.join().unwrap()) | ||
| 1613 | .collect(); | ||
| 1614 | assert_eq!(ids.len(), 12); | ||
| 1615 | assert!( | ||
| 1616 | started.elapsed() < std::time::Duration::from_secs(2), | ||
| 1617 | "Local edits waited for remote {phase}" | ||
| 1618 | ); | ||
| 1619 | resume_tx.send(()).unwrap(); | ||
| 1620 | running.join().unwrap() | ||
| 1621 | }); | ||
| 1622 | assert_eq!(cache.pending().unwrap().len(), 12); | ||
| 1623 | let expected = text(&cache.snapshot().unwrap()).2; | ||
| 1624 | for _ in 0..12 { | ||
| 1625 | assert!(matches!( | ||
| 1626 | cache.sync_once(&mut server).unwrap(), | ||
| 1627 | Some((_, EditStatus::Published { .. })) | ||
| 1628 | )); | ||
| 1629 | } | ||
| 1630 | assert!(cache.pending().unwrap().is_empty()); | ||
| 1631 | assert_eq!(server.publications, 13); | ||
| 1632 | assert_eq!(text(&server.durable).2, expected); | ||
| 1633 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 1634 | } | ||
| 1635 | } | ||
| 1636 | |||
| 1637 | #[test] | ||
| 1638 | fn unrelated_remote_files_never_replace_a_local_cache() { | ||
| 1639 | let dir = tempfile::tempdir().unwrap(); | ||
| 1640 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1641 | let (sid, oid, _) = text(&source); | ||
| 1642 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | ||
| 1643 | let other = onestore::create_section("other.one", "abc", "Fixture").unwrap(); | ||
| 1644 | let mut server = Server::new(&other); | ||
| 1645 | for pending in [false, true] { | ||
| 1646 | if pending { | ||
| 1647 | cache.edit_text(&source, sid, oid, 0..0, "Local ").unwrap(); | ||
| 1648 | } | ||
| 1649 | let before = cache.snapshot().unwrap(); | ||
| 1650 | assert!( | ||
| 1651 | matches!(cache.sync_once(&mut server),Err(Error::Io(error)) if error.kind()==io::ErrorKind::InvalidInput) | ||
| 1652 | ); | ||
| 1653 | assert_eq!(cache.snapshot().unwrap(), before); | ||
| 1654 | assert_eq!(cache.remote_snapshot().unwrap(), source); | ||
| 1655 | assert_eq!(server.publications, 0); | ||
| 1656 | } | ||
| 1657 | } | ||
| 1658 | |||
| 1659 | #[test] | ||
| 1660 | fn version_one_cache_migration_preserves_images_intents_and_local_ids() { | ||
| 1661 | let dir = tempfile::tempdir().unwrap(); | ||
| 1662 | let path = dir.path().join("cache.sqlite"); | ||
| 1663 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1664 | let (sid, oid, _) = text(&source); | ||
| 1665 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1666 | let id = cache | ||
| 1667 | .edit_text(&source, sid, oid, 0..0, "Local ") | ||
| 1668 | .unwrap() | ||
| 1669 | .unwrap(); | ||
| 1670 | let snapshot = cache.snapshot().unwrap(); | ||
| 1671 | let pending = cache.pending().unwrap(); | ||
| 1672 | drop(cache); | ||
| 1673 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 1674 | let current_version: u32 = db | ||
| 1675 | .pragma_query_value(None, "user_version", |row| row.get(0)) | ||
| 1676 | .unwrap(); | ||
| 1677 | db.execute_batch( | ||
| 1678 | "DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts; DROP TABLE edits; DROP TABLE assets; | ||
| 1679 | CREATE TABLE edits ( | ||
| 1680 | id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), space TEXT NOT NULL, | ||
| 1681 | object TEXT NOT NULL, before_text TEXT NOT NULL, | ||
| 1682 | start INTEGER NOT NULL CHECK(start BETWEEN 0 AND 4294967295), | ||
| 1683 | end INTEGER NOT NULL CHECK(end BETWEEN start AND 4294967295), replacement TEXT NOT NULL | ||
| 1684 | ) STRICT; PRAGMA user_version=1;", | ||
| 1685 | ) | ||
| 1686 | .unwrap(); | ||
| 1687 | db.execute("INSERT INTO edits(id,space,object,before_text,start,end,replacement) VALUES (?1,?2,?3,'abc',0,0,'Local ')",rusqlite::params![i64::try_from(id).unwrap(),sid.to_string(),oid.to_string()]).unwrap(); | ||
| 1688 | drop(db); | ||
| 1689 | let cache = Replica::open(&path).unwrap(); | ||
| 1690 | assert_eq!(cache.snapshot().unwrap(), snapshot); | ||
| 1691 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 1692 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 1693 | let mut server = Server::new(&source); | ||
| 1694 | assert!(matches!( | ||
| 1695 | cache.sync_once(&mut server).unwrap(), | ||
| 1696 | Some((_, EditStatus::Published { .. })) | ||
| 1697 | )); | ||
| 1698 | drop(cache); | ||
| 1699 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 1700 | assert_eq!( | ||
| 1701 | db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) | ||
| 1702 | .unwrap(), | ||
| 1703 | current_version | ||
| 1704 | ); | ||
| 1705 | } | ||
| 1706 | |||
| 1707 | #[test] | ||
| 1708 | fn reviewed_conflict_rebase_preserves_twelve_dependent_edits_and_survives_reopen() { | ||
| 1709 | let dir = tempfile::tempdir().unwrap(); | ||
| 1710 | let path = dir.path().join("cache.sqlite"); | ||
| 1711 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | ||
| 1712 | let (sid, oid, _) = text(&source); | ||
| 1713 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1714 | let first = cache | ||
| 1715 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 1716 | .unwrap() | ||
| 1717 | .unwrap(); | ||
| 1718 | for n in 0..12 { | ||
| 1719 | cache | ||
| 1720 | .edit_text( | ||
| 1721 | &cache.snapshot().unwrap(), | ||
| 1722 | sid, | ||
| 1723 | oid, | ||
| 1724 | 0..0, | ||
| 1725 | &format!("[{n}] "), | ||
| 1726 | ) | ||
| 1727 | .unwrap(); | ||
| 1728 | } | ||
| 1729 | let local = cache.snapshot().unwrap(); | ||
| 1730 | let pending = cache.pending().unwrap(); | ||
| 1731 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "aRcZ").unwrap(); | ||
| 1732 | let mut server = Server::new(&remote); | ||
| 1733 | assert_eq!( | ||
| 1734 | cache.sync_once(&mut server).unwrap(), | ||
| 1735 | Some((first, EditStatus::Conflict(ConflictKind::TextChanged))) | ||
| 1736 | ); | ||
| 1737 | cache.rebase_conflict(first, &local, &remote, 1..2).unwrap(); | ||
| 1738 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 1739 | assert_eq!(cache.remote_snapshot().unwrap(), remote); | ||
| 1740 | let rebased = cache.pending().unwrap(); | ||
| 1741 | assert_eq!(&rebased[1..], &pending[1..]); | ||
| 1742 | assert_eq!(rebased[0].id, first); | ||
| 1743 | let notebook::Operation::Text(updated) = &rebased[0].operation else { | ||
| 1744 | panic!() | ||
| 1745 | }; | ||
| 1746 | let notebook::Operation::Text(previous) = &pending[0].operation else { | ||
| 1747 | panic!() | ||
| 1748 | }; | ||
| 1749 | assert_eq!(updated.replacement, previous.replacement); | ||
| 1750 | assert_eq!(updated.before, "aRcZ"); | ||
| 1751 | assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending)); | ||
| 1752 | drop(cache); | ||
| 1753 | let cache = Replica::open(&path).unwrap(); | ||
| 1754 | assert_eq!(cache.pending().unwrap(), rebased); | ||
| 1755 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 1756 | for intent in &pending { | ||
| 1757 | assert!( | ||
| 1758 | matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == intent.id) | ||
| 1759 | ); | ||
| 1760 | } | ||
| 1761 | assert_eq!(server.publications, 13); | ||
| 1762 | assert_eq!(text(&server.durable).2, text(&local).2 + "Z"); | ||
| 1763 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 1764 | assert!(cache.pending().unwrap().is_empty()); | ||
| 1765 | } | ||
| 1766 | |||
| 1767 | #[test] | ||
| 1768 | fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states() { | ||
| 1769 | let dir = tempfile::tempdir().unwrap(); | ||
| 1770 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | ||
| 1771 | let (sid, oid, _) = text(&source); | ||
| 1772 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | ||
| 1773 | let id = cache | ||
| 1774 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 1775 | .unwrap() | ||
| 1776 | .unwrap(); | ||
| 1777 | let local = cache.snapshot().unwrap(); | ||
| 1778 | assert!( | ||
| 1779 | matches!(cache.rebase_conflict(id, &local, &source, 1..2), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) | ||
| 1780 | ); | ||
| 1781 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "🦀Rc").unwrap(); | ||
| 1782 | let mut server = Server::new(&remote); | ||
| 1783 | assert!(matches!( | ||
| 1784 | cache.sync_once(&mut server).unwrap(), | ||
| 1785 | Some((_, EditStatus::Conflict(_))) | ||
| 1786 | )); | ||
| 1787 | let pending = cache.pending().unwrap(); | ||
| 1788 | for range in [1..2, 100..101] { | ||
| 1789 | assert!(matches!( | ||
| 1790 | cache.rebase_conflict(id, &local, &remote, range), | ||
| 1791 | Err(Error::Document(_)) | ||
| 1792 | )); | ||
| 1793 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 1794 | } | ||
| 1795 | assert!( | ||
| 1796 | matches!(cache.rebase_conflict(id + 1, &local, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) | ||
| 1797 | ); | ||
| 1798 | cache.edit_text(&local, sid, oid, 0..0, "Later ").unwrap(); | ||
| 1799 | let changed = cache.snapshot().unwrap(); | ||
| 1800 | assert!( | ||
| 1801 | matches!(cache.rebase_conflict(id, &local, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy) | ||
| 1802 | ); | ||
| 1803 | let new_remote = onestore::replace_text(&remote, sid, oid, 2..3, "Q").unwrap(); | ||
| 1804 | server.visible = new_remote.clone(); | ||
| 1805 | server.durable = new_remote; | ||
| 1806 | assert!(matches!( | ||
| 1807 | cache.sync_once(&mut server).unwrap(), | ||
| 1808 | Some((_, EditStatus::Conflict(_))) | ||
| 1809 | )); | ||
| 1810 | assert!( | ||
| 1811 | matches!(cache.rebase_conflict(id, &changed, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy) | ||
| 1812 | ); | ||
| 1813 | assert_eq!(cache.snapshot().unwrap(), changed); | ||
| 1814 | assert_eq!(cache.pending().unwrap()[0], pending[0]); | ||
| 1815 | assert_eq!(server.publications, 0); | ||
| 1816 | |||
| 1817 | let current = cache.remote_snapshot().unwrap(); | ||
| 1818 | cache.rebase_conflict(id, &changed, &current, 2..3).unwrap(); | ||
| 1819 | server.fault = Fault::UnknownBefore; | ||
| 1820 | assert!( | ||
| 1821 | matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Unknown) | ||
| 1822 | ); | ||
| 1823 | let attempted = cache.status(id).unwrap(); | ||
| 1824 | let pending = cache.pending().unwrap(); | ||
| 1825 | assert!( | ||
| 1826 | matches!(cache.rebase_conflict(id, &changed, &current, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) | ||
| 1827 | ); | ||
| 1828 | assert_eq!(cache.status(id).unwrap(), attempted); | ||
| 1829 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 1830 | assert_eq!(server.publications, 1); | ||
| 1831 | } | ||
| 1832 | |||
| 1833 | #[test] | ||
| 1834 | fn failure_between_rebase_and_conflict_clear_rolls_back_the_entire_resolution() { | ||
| 1835 | let dir = tempfile::tempdir().unwrap(); | ||
| 1836 | let path = dir.path().join("cache.sqlite"); | ||
| 1837 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | ||
| 1838 | let (sid, oid, _) = text(&source); | ||
| 1839 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1840 | let id = cache | ||
| 1841 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 1842 | .unwrap() | ||
| 1843 | .unwrap(); | ||
| 1844 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "XaRc").unwrap(); | ||
| 1845 | let mut server = Server::new(&remote); | ||
| 1846 | cache.sync_once(&mut server).unwrap(); | ||
| 1847 | let local = cache.snapshot().unwrap(); | ||
| 1848 | let pending = cache.pending().unwrap(); | ||
| 1849 | let status = cache.status(id).unwrap(); | ||
| 1850 | drop(cache); | ||
| 1851 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 1852 | connection.execute_batch("CREATE TRIGGER fail_clear BEFORE DELETE ON conflicts BEGIN SELECT RAISE(ABORT, 'test conflict clear failure'); END;").unwrap(); | ||
| 1853 | drop(connection); | ||
| 1854 | let cache = Replica::open(&path).unwrap(); | ||
| 1855 | assert!(matches!( | ||
| 1856 | cache.rebase_conflict(id, &local, &remote, 2..3), | ||
| 1857 | Err(Error::Database(_)) | ||
| 1858 | )); | ||
| 1859 | drop(cache); | ||
| 1860 | let cache = Replica::open(&path).unwrap(); | ||
| 1861 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 1862 | assert_eq!(cache.status(id).unwrap(), status); | ||
| 1863 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 1864 | assert_eq!(cache.remote_snapshot().unwrap(), remote); | ||
| 1865 | assert_eq!(server.publications, 0); | ||
| 1866 | } | ||
| 1867 | |||
| 1868 | #[test] | ||
| 1869 | fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replacement() { | ||
| 1870 | let dir = tempfile::tempdir().unwrap(); | ||
| 1871 | let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect(); | ||
| 1872 | let mut seed = 911_u64; | ||
| 1873 | for case in 0..64 { | ||
| 1874 | seed ^= seed << 13; | ||
| 1875 | seed ^= seed >> 7; | ||
| 1876 | seed ^= seed << 17; | ||
| 1877 | let at = seed as usize % original.len(); | ||
| 1878 | let prefix = "[".repeat((seed >> 8) as usize % 5); | ||
| 1879 | let suffix = "]".repeat((seed >> 16) as usize % 5); | ||
| 1880 | let start: u32 = original[..at].iter().map(|ch| ch.len_utf16() as u32).sum(); | ||
| 1881 | let end = start + original[at].len_utf16() as u32; | ||
| 1882 | let source = onestore::create_section( | ||
| 1883 | "resolve.one", | ||
| 1884 | &original.iter().collect::<String>(), | ||
| 1885 | "Fixture", | ||
| 1886 | ) | ||
| 1887 | .unwrap(); | ||
| 1888 | let (sid, oid, _) = text(&source); | ||
| 1889 | let cache = Replica::create(dir.path().join(format!("{case}.sqlite")), &source).unwrap(); | ||
| 1890 | let id = cache | ||
| 1891 | .edit_text(&source, sid, oid, start..end, "λ🦊μ") | ||
| 1892 | .unwrap() | ||
| 1893 | .unwrap(); | ||
| 1894 | let dependent = cache | ||
| 1895 | .edit_text( | ||
| 1896 | &cache.snapshot().unwrap(), | ||
| 1897 | sid, | ||
| 1898 | oid, | ||
| 1899 | start + 1..start + 3, | ||
| 1900 | "🐕", | ||
| 1901 | ) | ||
| 1902 | .unwrap() | ||
| 1903 | .unwrap(); | ||
| 1904 | let mut remote_text = original.clone(); | ||
| 1905 | remote_text.splice(at..at + 1, "Ω🐈π".chars()); | ||
| 1906 | let remote_text = prefix.clone() + &remote_text.iter().collect::<String>() + &suffix; | ||
| 1907 | let remote = onestore::replace_text( | ||
| 1908 | &source, | ||
| 1909 | sid, | ||
| 1910 | oid, | ||
| 1911 | 0..original.iter().map(|ch| ch.len_utf16() as u32).sum(), | ||
| 1912 | &remote_text, | ||
| 1913 | ) | ||
| 1914 | .unwrap(); | ||
| 1915 | let mut server = Server::new(&remote); | ||
| 1916 | assert!( | ||
| 1917 | matches!( | ||
| 1918 | cache.sync_once(&mut server).unwrap(), | ||
| 1919 | Some((_, EditStatus::Conflict(_))) | ||
| 1920 | ), | ||
| 1921 | "case {case}, seed {seed}" | ||
| 1922 | ); | ||
| 1923 | let at_remote = start + prefix.len() as u32; | ||
| 1924 | cache | ||
| 1925 | .rebase_conflict( | ||
| 1926 | id, | ||
| 1927 | &cache.snapshot().unwrap(), | ||
| 1928 | &remote, | ||
| 1929 | at_remote..at_remote + 4, | ||
| 1930 | ) | ||
| 1931 | .unwrap(); | ||
| 1932 | for expected in [id, dependent] { | ||
| 1933 | assert!( | ||
| 1934 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected), | ||
| 1935 | "case {case}, seed {seed}" | ||
| 1936 | ); | ||
| 1937 | } | ||
| 1938 | let mut expected = original.clone(); | ||
| 1939 | expected.splice(at..at + 1, "λ🐕μ".chars()); | ||
| 1940 | let expected = prefix + &expected.iter().collect::<String>() + &suffix; | ||
| 1941 | assert_eq!( | ||
| 1942 | text(&server.durable).2, | ||
| 1943 | expected, | ||
| 1944 | "case {case}, seed {seed}" | ||
| 1945 | ); | ||
| 1946 | assert_eq!(server.publications, 2); | ||
| 1947 | assert!(cache.pending().unwrap().is_empty()); | ||
| 1948 | } | ||
| 1949 | } | ||
| 1950 | |||
| 1951 | #[test] | ||
| 1952 | fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() { | ||
| 1953 | struct ChangedAfterRead { | ||
| 1954 | server: Server, | ||
| 1955 | change: Option<Vec<u8>>, | ||
| 1956 | } | ||
| 1957 | impl Remote for ChangedAfterRead { | ||
| 1958 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 1959 | let snapshot = self.server.read()?; | ||
| 1960 | if let Some(changed) = self.change.take() { | ||
| 1961 | self.server.visible = changed.clone(); | ||
| 1962 | self.server.durable = changed; | ||
| 1963 | } | ||
| 1964 | Ok(snapshot) | ||
| 1965 | } | ||
| 1966 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 1967 | self.server.publish(edit) | ||
| 1968 | } | ||
| 1969 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 1970 | self.server.confirm(snapshot) | ||
| 1971 | } | ||
| 1972 | } | ||
| 1973 | for after_read in [false, true] { | ||
| 1974 | let dir = tempfile::tempdir().unwrap(); | ||
| 1975 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | ||
| 1976 | let (sid, oid, _) = text(&source); | ||
| 1977 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | ||
| 1978 | let id = cache | ||
| 1979 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 1980 | .unwrap() | ||
| 1981 | .unwrap(); | ||
| 1982 | let local = cache.snapshot().unwrap(); | ||
| 1983 | let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); | ||
| 1984 | let mut remote = ChangedAfterRead { | ||
| 1985 | server: Server::new(&remote), | ||
| 1986 | change: None, | ||
| 1987 | }; | ||
| 1988 | assert!(matches!( | ||
| 1989 | cache.sync_once(&mut remote).unwrap(), | ||
| 1990 | Some((_, EditStatus::Conflict(_))) | ||
| 1991 | )); | ||
| 1992 | cache | ||
| 1993 | .rebase_conflict(id, &local, &cache.remote_snapshot().unwrap(), 1..2) | ||
| 1994 | .unwrap(); | ||
| 1995 | let changed = onestore::replace_text(&remote.server.visible, sid, oid, 1..2, "Q").unwrap(); | ||
| 1996 | if after_read { | ||
| 1997 | remote.change = Some(changed.clone()); | ||
| 1998 | } else { | ||
| 1999 | remote.server.visible = changed.clone(); | ||
| 2000 | remote.server.durable = changed.clone(); | ||
| 2001 | } | ||
| 2002 | if after_read { | ||
| 2003 | assert!( | ||
| 2004 | matches!(cache.sync_once(&mut remote), Err(Error::Remote(error)) if error.state == CommitState::NotCommitted) | ||
| 2005 | ); | ||
| 2006 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 2007 | } | ||
| 2008 | assert_eq!( | ||
| 2009 | cache.sync_once(&mut remote).unwrap(), | ||
| 2010 | Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) | ||
| 2011 | ); | ||
| 2012 | assert_eq!(remote.server.durable, changed); | ||
| 2013 | assert_eq!(remote.server.visible, changed); | ||
| 2014 | assert_eq!(remote.server.publications, usize::from(after_read)); | ||
| 2015 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 2016 | let notebook::Operation::Text(edit) = &cache.pending().unwrap()[0].operation else { | ||
| 2017 | panic!() | ||
| 2018 | }; | ||
| 2019 | assert_eq!(edit.replacement, "L"); | ||
| 2020 | } | ||
| 2021 | } | ||
| 2022 | |||
| 2023 | mod worker { | ||
| 2024 | use super::*; | ||
| 2025 | use std::{ | ||
| 2026 | sync::{Arc, Mutex, mpsc}, | ||
| 2027 | time::{Duration, Instant}, | ||
| 2028 | }; | ||
| 2029 | |||
| 2030 | #[derive(Clone)] | ||
| 2031 | struct Shared(Arc<Mutex<Server>>); | ||
| 2032 | |||
| 2033 | impl Remote for Shared { | ||
| 2034 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 2035 | self.0.lock().unwrap().read() | ||
| 2036 | } | ||
| 2037 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 2038 | self.0.lock().unwrap().publish(edit) | ||
| 2039 | } | ||
| 2040 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 2041 | self.0.lock().unwrap().confirm(snapshot) | ||
| 2042 | } | ||
| 2043 | } | ||
| 2044 | |||
| 2045 | #[test] | ||
| 2046 | fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() { | ||
| 2047 | struct Session { | ||
| 2048 | shared: Shared, | ||
| 2049 | fail_read: bool, | ||
| 2050 | } | ||
| 2051 | impl Remote for Session { | ||
| 2052 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 2053 | if self.fail_read { | ||
| 2054 | return Err(io::ErrorKind::ConnectionReset.into()); | ||
| 2055 | } | ||
| 2056 | self.shared.read() | ||
| 2057 | } | ||
| 2058 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 2059 | self.shared.publish(edit) | ||
| 2060 | } | ||
| 2061 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 2062 | self.shared.confirm(snapshot) | ||
| 2063 | } | ||
| 2064 | } | ||
| 2065 | let dir = tempfile::tempdir().unwrap(); | ||
| 2066 | let path = dir.path().join("cache.sqlite"); | ||
| 2067 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2068 | let (sid, oid, _) = text(&source); | ||
| 2069 | let cache = Arc::new(Replica::create(&path, &source).unwrap()); | ||
| 2070 | let id = cache | ||
| 2071 | .edit_text(&source, sid, oid, 1..2, "🦀") | ||
| 2072 | .unwrap() | ||
| 2073 | .unwrap(); | ||
| 2074 | let mut server = Server::new(&source); | ||
| 2075 | server.fault = Fault::UnknownAfter; | ||
| 2076 | let server = Arc::new(Mutex::new(server)); | ||
| 2077 | let shared = Shared(Arc::clone(&server)); | ||
| 2078 | let (connected_tx, connected_rx) = mpsc::channel(); | ||
| 2079 | let (observed_tx, observed_rx) = mpsc::channel(); | ||
| 2080 | let mut connections = 0; | ||
| 2081 | let worker = cache | ||
| 2082 | .start_sync( | ||
| 2083 | Duration::from_millis(10), | ||
| 2084 | move || { | ||
| 2085 | connections += 1; | ||
| 2086 | connected_tx.send(connections).unwrap(); | ||
| 2087 | if connections <= 2 { | ||
| 2088 | return Err(io::ErrorKind::ConnectionRefused.into()); | ||
| 2089 | } | ||
| 2090 | Ok(Session { | ||
| 2091 | shared: shared.clone(), | ||
| 2092 | fail_read: connections == 3, | ||
| 2093 | }) | ||
| 2094 | }, | ||
| 2095 | move |result| { | ||
| 2096 | observed_tx | ||
| 2097 | .send(result.as_ref().copied().map_err(|error| error.to_string())) | ||
| 2098 | .unwrap(); | ||
| 2099 | }, | ||
| 2100 | ) | ||
| 2101 | .unwrap(); | ||
| 2102 | let mut errors = 0; | ||
| 2103 | let published = loop { | ||
| 2104 | match observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() { | ||
| 2105 | Err(_) => errors += 1, | ||
| 2106 | Ok(Some((actual, status @ EditStatus::Published { .. }))) => { | ||
| 2107 | assert_eq!(actual, id); | ||
| 2108 | break status; | ||
| 2109 | } | ||
| 2110 | other => panic!("Unexpected result: {other:?}"), | ||
| 2111 | } | ||
| 2112 | }; | ||
| 2113 | worker.stop().unwrap(); | ||
| 2114 | assert_eq!(errors, 4); | ||
| 2115 | assert_eq!(connected_rx.try_iter().collect::<Vec<_>>(), [1, 2, 3, 4, 5]); | ||
| 2116 | let server = server.lock().unwrap(); | ||
| 2117 | assert_eq!(server.publications, 1); | ||
| 2118 | assert_eq!(server.confirmations, 1); | ||
| 2119 | assert_eq!(text(&server.durable).2, "a🦀c"); | ||
| 2120 | drop(cache); | ||
| 2121 | let cache = Replica::open(&path).unwrap(); | ||
| 2122 | assert_eq!(cache.status(id).unwrap(), Some(published)); | ||
| 2123 | let cached = cache.snapshot().unwrap(); | ||
| 2124 | // Confirmation changes reader-notification fields without changing the committed graph. | ||
| 2125 | assert_eq!(cached[..212], server.durable[..212]); | ||
| 2126 | assert_eq!(cached[252..], server.durable[252..]); | ||
| 2127 | let cached_generation = Store::parse(&cached).unwrap().header.generation; | ||
| 2128 | let remote_generation = Store::parse(&server.durable).unwrap().header.generation; | ||
| 2129 | if cached_generation == remote_generation { | ||
| 2130 | assert_eq!(cached, server.durable); | ||
| 2131 | } else { | ||
| 2132 | assert_eq!(cached_generation.checked_add(1), Some(remote_generation)); | ||
| 2133 | } | ||
| 2134 | } | ||
| 2135 | |||
| 2136 | #[test] | ||
| 2137 | fn local_edits_wake_an_idle_worker_and_coalesced_notifications_drain_twelve_writers() { | ||
| 2138 | let dir = tempfile::tempdir().unwrap(); | ||
| 2139 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2140 | let (sid, oid, _) = text(&source); | ||
| 2141 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2142 | let server = Arc::new(Mutex::new(Server::new(&source))); | ||
| 2143 | let shared = Shared(Arc::clone(&server)); | ||
| 2144 | let (observed_tx, observed_rx) = mpsc::channel(); | ||
| 2145 | let (resume_tx, resume_rx) = mpsc::channel(); | ||
| 2146 | let mut first = true; | ||
| 2147 | let worker = cache | ||
| 2148 | .start_sync( | ||
| 2149 | Duration::from_secs(3600), | ||
| 2150 | move || Ok(shared.clone()), | ||
| 2151 | move |result| { | ||
| 2152 | observed_tx.send(*result.as_ref().unwrap()).unwrap(); | ||
| 2153 | if first { | ||
| 2154 | first = false; | ||
| 2155 | resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2156 | } | ||
| 2157 | }, | ||
| 2158 | ) | ||
| 2159 | .unwrap(); | ||
| 2160 | assert_eq!( | ||
| 2161 | observed_rx.recv_timeout(Duration::from_secs(5)).unwrap(), | ||
| 2162 | None | ||
| 2163 | ); | ||
| 2164 | let started = Instant::now(); | ||
| 2165 | let edits = std::thread::scope(|scope| { | ||
| 2166 | (0..12) | ||
| 2167 | .map(|writer| { | ||
| 2168 | let cache = &cache; | ||
| 2169 | scope.spawn(move || { | ||
| 2170 | let replacement = format!("[{writer}] "); | ||
| 2171 | loop { | ||
| 2172 | let snapshot = cache.snapshot().unwrap(); | ||
| 2173 | match cache.edit_text(&snapshot, sid, oid, 0..0, &replacement) { | ||
| 2174 | Ok(Some(id)) => break (id, replacement), | ||
| 2175 | Err(Error::Io(error)) | ||
| 2176 | if error.kind() == io::ErrorKind::ResourceBusy => {} | ||
| 2177 | other => panic!("Unexpected local outcome: {other:?}"), | ||
| 2178 | } | ||
| 2179 | } | ||
| 2180 | }) | ||
| 2181 | }) | ||
| 2182 | .collect::<Vec<_>>() | ||
| 2183 | .into_iter() | ||
| 2184 | .map(|join| join.join().unwrap()) | ||
| 2185 | .collect::<std::collections::BTreeMap<_, _>>() | ||
| 2186 | }); | ||
| 2187 | resume_tx.send(()).unwrap(); | ||
| 2188 | let mut published = std::collections::BTreeSet::new(); | ||
| 2189 | while published.len() < 12 { | ||
| 2190 | if let Some((id, status)) = observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() { | ||
| 2191 | assert!(matches!(status, EditStatus::Published { .. }), "{status:?}"); | ||
| 2192 | assert!(published.insert(id)); | ||
| 2193 | } | ||
| 2194 | } | ||
| 2195 | assert!( | ||
| 2196 | started.elapsed() < Duration::from_secs(5), | ||
| 2197 | "Edits waited for the hourly poll" | ||
| 2198 | ); | ||
| 2199 | worker.stop().unwrap(); | ||
| 2200 | assert_eq!(published, edits.keys().copied().collect()); | ||
| 2201 | let expected = edits.values().rev().cloned().collect::<String>() + "abc"; | ||
| 2202 | let server = server.lock().unwrap(); | ||
| 2203 | assert_eq!(text(&server.durable).2, expected); | ||
| 2204 | assert_eq!(server.publications, 12); | ||
| 2205 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 2206 | assert!(cache.pending().unwrap().is_empty()); | ||
| 2207 | } | ||
| 2208 | |||
| 2209 | #[test] | ||
| 2210 | fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recovery_is_recorded() | ||
| 2211 | { | ||
| 2212 | struct Paused { | ||
| 2213 | shared: Shared, | ||
| 2214 | entered: mpsc::Sender<()>, | ||
| 2215 | resume: mpsc::Receiver<()>, | ||
| 2216 | } | ||
| 2217 | impl Remote for Paused { | ||
| 2218 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 2219 | self.shared.read() | ||
| 2220 | } | ||
| 2221 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 2222 | self.entered.send(()).unwrap(); | ||
| 2223 | self.resume.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2224 | self.shared.publish(edit) | ||
| 2225 | } | ||
| 2226 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 2227 | self.shared.confirm(snapshot) | ||
| 2228 | } | ||
| 2229 | } | ||
| 2230 | let dir = tempfile::tempdir().unwrap(); | ||
| 2231 | let path = dir.path().join("cache.sqlite"); | ||
| 2232 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2233 | let (sid, oid, _) = text(&source); | ||
| 2234 | let cache = Arc::new(Replica::create(&path, &source).unwrap()); | ||
| 2235 | let id = cache | ||
| 2236 | .edit_text(&source, sid, oid, 0..0, "L ") | ||
| 2237 | .unwrap() | ||
| 2238 | .unwrap(); | ||
| 2239 | let mut server = Server::new(&source); | ||
| 2240 | server.fault = Fault::UnknownAfter; | ||
| 2241 | let server = Arc::new(Mutex::new(server)); | ||
| 2242 | let shared = Shared(Arc::clone(&server)); | ||
| 2243 | let (entered_tx, entered_rx) = mpsc::channel(); | ||
| 2244 | let (resume_tx, resume_rx) = mpsc::channel(); | ||
| 2245 | let mut session = Some(Paused { | ||
| 2246 | shared, | ||
| 2247 | entered: entered_tx, | ||
| 2248 | resume: resume_rx, | ||
| 2249 | }); | ||
| 2250 | let worker = cache | ||
| 2251 | .start_sync( | ||
| 2252 | Duration::from_secs(3600), | ||
| 2253 | move || Ok(session.take().unwrap()), | ||
| 2254 | |_| {}, | ||
| 2255 | ) | ||
| 2256 | .unwrap(); | ||
| 2257 | entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2258 | let stopped = Instant::now(); | ||
| 2259 | drop(worker); | ||
| 2260 | assert!(stopped.elapsed() < Duration::from_millis(500)); | ||
| 2261 | let shared = Shared(Arc::clone(&server)); | ||
| 2262 | let start = cache.start_sync( | ||
| 2263 | Duration::from_secs(3600), | ||
| 2264 | move || Ok(shared.clone()), | ||
| 2265 | |_| {}, | ||
| 2266 | ); | ||
| 2267 | assert!(matches!(start, Err(error) if error.kind() == io::ErrorKind::WouldBlock)); | ||
| 2268 | let weak = Arc::downgrade(&cache); | ||
| 2269 | drop(cache); | ||
| 2270 | resume_tx.send(()).unwrap(); | ||
| 2271 | while weak.upgrade().is_some() { | ||
| 2272 | assert!(stopped.elapsed() < Duration::from_secs(5)); | ||
| 2273 | std::thread::sleep(Duration::from_millis(1)); | ||
| 2274 | } | ||
| 2275 | let cache = Arc::new(Replica::open(&path).unwrap()); | ||
| 2276 | assert!(matches!( | ||
| 2277 | cache.status(id).unwrap(), | ||
| 2278 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 2279 | )); | ||
| 2280 | assert_eq!(server.lock().unwrap().publications, 1); | ||
| 2281 | let shared = Shared(Arc::clone(&server)); | ||
| 2282 | let (tx, rx) = mpsc::channel(); | ||
| 2283 | let worker = cache | ||
| 2284 | .start_sync( | ||
| 2285 | Duration::from_secs(3600), | ||
| 2286 | move || Ok(shared.clone()), | ||
| 2287 | move |result| { | ||
| 2288 | tx.send(*result.as_ref().unwrap()).unwrap(); | ||
| 2289 | }, | ||
| 2290 | ) | ||
| 2291 | .unwrap(); | ||
| 2292 | assert!( | ||
| 2293 | matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | ||
| 2294 | ); | ||
| 2295 | worker.stop().unwrap(); | ||
| 2296 | let server = server.lock().unwrap(); | ||
| 2297 | assert_eq!(server.publications, 1); | ||
| 2298 | assert_eq!(server.confirmations, 1); | ||
| 2299 | assert_eq!(text(&server.durable).2, "L abc"); | ||
| 2300 | } | ||
| 2301 | |||
| 2302 | #[test] | ||
| 2303 | fn cache_failures_stop_retries_and_return_the_error_without_remote_publication() { | ||
| 2304 | let dir = tempfile::tempdir().unwrap(); | ||
| 2305 | let path = dir.path().join("cache.sqlite"); | ||
| 2306 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2307 | let (sid, oid, _) = text(&source); | ||
| 2308 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 2309 | let id = cache | ||
| 2310 | .edit_text(&source, sid, oid, 0..0, "L ") | ||
| 2311 | .unwrap() | ||
| 2312 | .unwrap(); | ||
| 2313 | drop(cache); | ||
| 2314 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 2315 | connection.execute_batch("CREATE TRIGGER fail_attempt BEFORE INSERT ON attempt BEGIN SELECT RAISE(ABORT, 'test cache write failure'); END;").unwrap(); | ||
| 2316 | drop(connection); | ||
| 2317 | let cache = Arc::new(Replica::open(&path).unwrap()); | ||
| 2318 | let server = Arc::new(Mutex::new(Server::new(&source))); | ||
| 2319 | let shared = Shared(Arc::clone(&server)); | ||
| 2320 | let (tx, rx) = mpsc::channel(); | ||
| 2321 | let worker = cache | ||
| 2322 | .start_sync( | ||
| 2323 | Duration::from_millis(1), | ||
| 2324 | move || Ok(shared.clone()), | ||
| 2325 | move |result| { | ||
| 2326 | tx.send(matches!(result, Err(Error::Database(_)))).unwrap(); | ||
| 2327 | }, | ||
| 2328 | ) | ||
| 2329 | .unwrap(); | ||
| 2330 | assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); | ||
| 2331 | assert!(matches!(worker.stop(), Err(Error::Database(_)))); | ||
| 2332 | assert!(rx.try_iter().next().is_none()); | ||
| 2333 | assert_eq!(server.lock().unwrap().publications, 0); | ||
| 2334 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 2335 | assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc"); | ||
| 2336 | } | ||
| 2337 | |||
| 2338 | #[test] | ||
| 2339 | fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() { | ||
| 2340 | for uncertain in [false, true] { | ||
| 2341 | let dir = tempfile::tempdir().unwrap(); | ||
| 2342 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2343 | let (sid, oid, _) = text(&source); | ||
| 2344 | let cache = | ||
| 2345 | Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2346 | let id = cache | ||
| 2347 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 2348 | .unwrap() | ||
| 2349 | .unwrap(); | ||
| 2350 | let local = cache.snapshot().unwrap(); | ||
| 2351 | let mut server = if uncertain { | ||
| 2352 | Server::new(&source) | ||
| 2353 | } else { | ||
| 2354 | Server::new(&onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap()) | ||
| 2355 | }; | ||
| 2356 | if uncertain { | ||
| 2357 | server.fault = Fault::UnknownBefore; | ||
| 2358 | } | ||
| 2359 | let server = Arc::new(Mutex::new(server)); | ||
| 2360 | let shared = Shared(Arc::clone(&server)); | ||
| 2361 | let (tx, rx) = mpsc::channel(); | ||
| 2362 | let worker = cache | ||
| 2363 | .start_sync( | ||
| 2364 | Duration::from_millis(10), | ||
| 2365 | move || Ok(shared.clone()), | ||
| 2366 | move |result| { | ||
| 2367 | tx.send(result.as_ref().copied().map_err(|_| ())).unwrap(); | ||
| 2368 | }, | ||
| 2369 | ) | ||
| 2370 | .unwrap(); | ||
| 2371 | if uncertain { | ||
| 2372 | assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); | ||
| 2373 | } | ||
| 2374 | let mut previous = None; | ||
| 2375 | let started = Instant::now(); | ||
| 2376 | for _ in 0..5 { | ||
| 2377 | let (actual, status) = rx | ||
| 2378 | .recv_timeout(Duration::from_secs(5)) | ||
| 2379 | .unwrap() | ||
| 2380 | .unwrap() | ||
| 2381 | .unwrap(); | ||
| 2382 | assert_eq!(actual, id); | ||
| 2383 | if uncertain { | ||
| 2384 | assert!(matches!(status, EditStatus::AwaitingConfirmation { .. })); | ||
| 2385 | } else { | ||
| 2386 | assert_eq!(status, EditStatus::Conflict(ConflictKind::TextChanged)); | ||
| 2387 | } | ||
| 2388 | if let Some(previous) = previous { | ||
| 2389 | assert_eq!(previous, status); | ||
| 2390 | } | ||
| 2391 | previous = Some(status); | ||
| 2392 | } | ||
| 2393 | assert!( | ||
| 2394 | started.elapsed() >= Duration::from_millis(30), | ||
| 2395 | "Worker spun instead of waiting between retries" | ||
| 2396 | ); | ||
| 2397 | worker.stop().unwrap(); | ||
| 2398 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 2399 | assert_eq!(cache.pending().unwrap().len(), 1); | ||
| 2400 | let server = server.lock().unwrap(); | ||
| 2401 | assert_eq!(server.publications, usize::from(uncertain)); | ||
| 2402 | assert_eq!(server.confirmations, 0); | ||
| 2403 | } | ||
| 2404 | } | ||
| 2405 | |||
| 2406 | #[test] | ||
| 2407 | fn reachability_notification_retries_without_waiting_for_the_poll() { | ||
| 2408 | let dir = tempfile::tempdir().unwrap(); | ||
| 2409 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2410 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2411 | let (tx, rx) = mpsc::channel(); | ||
| 2412 | let worker = cache | ||
| 2413 | .start_sync( | ||
| 2414 | Duration::from_secs(3600), | ||
| 2415 | || -> io::Result<Shared> { Err(io::ErrorKind::NotConnected.into()) }, | ||
| 2416 | move |result| { | ||
| 2417 | tx.send(matches!(result, Err(Error::RemoteIo(_)))).unwrap(); | ||
| 2418 | }, | ||
| 2419 | ) | ||
| 2420 | .unwrap(); | ||
| 2421 | assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); | ||
| 2422 | worker.wake(); | ||
| 2423 | assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); | ||
| 2424 | worker.stop().unwrap(); | ||
| 2425 | assert!(rx.try_iter().next().is_none()); | ||
| 2426 | } | ||
| 2427 | |||
| 2428 | #[test] | ||
| 2429 | fn cancellation_during_connect_does_not_read_or_report_a_false_refresh() { | ||
| 2430 | let dir = tempfile::tempdir().unwrap(); | ||
| 2431 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2432 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2433 | let (entered_tx, entered_rx) = mpsc::channel(); | ||
| 2434 | let (resume_tx, resume_rx) = mpsc::channel(); | ||
| 2435 | let (tx, rx) = mpsc::channel(); | ||
| 2436 | // An invalid image makes any unexpected read observable as an error callback. | ||
| 2437 | let shared = Shared(Arc::new(Mutex::new(Server::new(&[])))); | ||
| 2438 | let worker = cache | ||
| 2439 | .start_sync( | ||
| 2440 | Duration::from_secs(3600), | ||
| 2441 | move || { | ||
| 2442 | entered_tx.send(()).unwrap(); | ||
| 2443 | resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2444 | Ok(shared.clone()) | ||
| 2445 | }, | ||
| 2446 | move |_| { | ||
| 2447 | tx.send(()).unwrap(); | ||
| 2448 | }, | ||
| 2449 | ) | ||
| 2450 | .unwrap(); | ||
| 2451 | entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2452 | drop(worker); | ||
| 2453 | let weak = Arc::downgrade(&cache); | ||
| 2454 | drop(cache); | ||
| 2455 | resume_tx.send(()).unwrap(); | ||
| 2456 | let started = Instant::now(); | ||
| 2457 | while weak.upgrade().is_some() { | ||
| 2458 | assert!(started.elapsed() < Duration::from_secs(5)); | ||
| 2459 | std::thread::sleep(Duration::from_millis(1)); | ||
| 2460 | } | ||
| 2461 | assert_eq!( | ||
| 2462 | rx.recv_timeout(Duration::from_secs(5)), | ||
| 2463 | Err(mpsc::RecvTimeoutError::Disconnected) | ||
| 2464 | ); | ||
| 2465 | } | ||
| 2466 | |||
| 2467 | #[test] | ||
| 2468 | fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() { | ||
| 2469 | let dir = tempfile::tempdir().unwrap(); | ||
| 2470 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2471 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2472 | for interval in [Duration::ZERO, Duration::MAX] { | ||
| 2473 | assert!( | ||
| 2474 | matches!(cache.start_sync(interval, || -> io::Result<Shared> { panic!("Unexpected connection") }, |_| {}), Err(error) if error.kind() == io::ErrorKind::InvalidInput) | ||
| 2475 | ); | ||
| 2476 | } | ||
| 2477 | let shared = Shared(Arc::new(Mutex::new(Server::new(&source)))); | ||
| 2478 | let first = shared.clone(); | ||
| 2479 | let (tx, rx) = mpsc::channel(); | ||
| 2480 | let worker = cache | ||
| 2481 | .start_sync( | ||
| 2482 | Duration::from_secs(3600), | ||
| 2483 | move || Ok(first.clone()), | ||
| 2484 | move |_| { | ||
| 2485 | tx.send(()).unwrap(); | ||
| 2486 | panic!("Test observer panic"); | ||
| 2487 | }, | ||
| 2488 | ) | ||
| 2489 | .unwrap(); | ||
| 2490 | rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2491 | assert!(matches!(worker.stop(), Err(Error::Io(_)))); | ||
| 2492 | let (tx, rx) = mpsc::channel(); | ||
| 2493 | let worker = cache | ||
| 2494 | .start_sync( | ||
| 2495 | Duration::from_secs(3600), | ||
| 2496 | move || Ok(shared.clone()), | ||
| 2497 | move |result| { | ||
| 2498 | tx.send(*result.as_ref().unwrap()).unwrap(); | ||
| 2499 | }, | ||
| 2500 | ) | ||
| 2501 | .unwrap(); | ||
| 2502 | assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), None); | ||
| 2503 | worker.stop().unwrap(); | ||
| 2504 | assert_eq!(cache.snapshot().unwrap(), source); | ||
| 2505 | } | ||
| 2506 | |||
| 2507 | #[test] | ||
| 2508 | fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() { | ||
| 2509 | let dir = tempfile::tempdir().unwrap(); | ||
| 2510 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | ||
| 2511 | let (sid, oid, _) = text(&source); | ||
| 2512 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2513 | let id = cache | ||
| 2514 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 2515 | .unwrap() | ||
| 2516 | .unwrap(); | ||
| 2517 | let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); | ||
| 2518 | let server = Arc::new(Mutex::new(Server::new(&remote))); | ||
| 2519 | let shared = Shared(Arc::clone(&server)); | ||
| 2520 | let (tx, rx) = mpsc::channel(); | ||
| 2521 | let (resume_tx, resume_rx) = mpsc::channel(); | ||
| 2522 | let mut first = true; | ||
| 2523 | let worker = cache | ||
| 2524 | .start_sync( | ||
| 2525 | Duration::from_secs(3600), | ||
| 2526 | move || Ok(shared.clone()), | ||
| 2527 | move |result| { | ||
| 2528 | tx.send(*result.as_ref().unwrap()).unwrap(); | ||
| 2529 | if first { | ||
| 2530 | first = false; | ||
| 2531 | resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2532 | } | ||
| 2533 | }, | ||
| 2534 | ) | ||
| 2535 | .unwrap(); | ||
| 2536 | assert_eq!( | ||
| 2537 | rx.recv_timeout(Duration::from_secs(5)).unwrap(), | ||
| 2538 | Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) | ||
| 2539 | ); | ||
| 2540 | cache | ||
| 2541 | .rebase_conflict( | ||
| 2542 | id, | ||
| 2543 | &cache.snapshot().unwrap(), | ||
| 2544 | &cache.remote_snapshot().unwrap(), | ||
| 2545 | 1..2, | ||
| 2546 | ) | ||
| 2547 | .unwrap(); | ||
| 2548 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 2549 | resume_tx.send(()).unwrap(); | ||
| 2550 | assert!( | ||
| 2551 | matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | ||
| 2552 | ); | ||
| 2553 | worker.stop().unwrap(); | ||
| 2554 | assert!(cache.pending().unwrap().is_empty()); | ||
| 2555 | let server = server.lock().unwrap(); | ||
| 2556 | assert_eq!(server.publications, 1); | ||
| 2557 | assert_eq!(text(&server.durable).2, "aLc"); | ||
| 2558 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 2559 | } | ||
| 2560 | |||
| 2561 | #[test] | ||
| 2562 | fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() { | ||
| 2563 | struct Busy { | ||
| 2564 | server: Server, | ||
| 2565 | reads: usize, | ||
| 2566 | writes: usize, | ||
| 2567 | } | ||
| 2568 | impl Remote for Busy { | ||
| 2569 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 2570 | self.reads += 1; | ||
| 2571 | match self.reads { | ||
| 2572 | 1 => Err(io::ErrorKind::WouldBlock.into()), | ||
| 2573 | 2 => Err(io::ErrorKind::ResourceBusy.into()), | ||
| 2574 | _ => self.server.read(), | ||
| 2575 | } | ||
| 2576 | } | ||
| 2577 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 2578 | self.writes += 1; | ||
| 2579 | match self.writes { | ||
| 2580 | 1 | 2 => Err(CommitError { | ||
| 2581 | state: CommitState::NotCommitted, | ||
| 2582 | error: if self.writes == 1 { | ||
| 2583 | io::ErrorKind::WouldBlock | ||
| 2584 | } else { | ||
| 2585 | io::ErrorKind::ResourceBusy | ||
| 2586 | } | ||
| 2587 | .into(), | ||
| 2588 | }), | ||
| 2589 | _ => self.server.publish(edit), | ||
| 2590 | } | ||
| 2591 | } | ||
| 2592 | fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { | ||
| 2593 | self.server.confirm(source) | ||
| 2594 | } | ||
| 2595 | } | ||
| 2596 | let dir = tempfile::tempdir().unwrap(); | ||
| 2597 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2598 | let (sid, oid, _) = text(&source); | ||
| 2599 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2600 | let id = cache | ||
| 2601 | .edit_text(&source, sid, oid, 0..0, "L ") | ||
| 2602 | .unwrap() | ||
| 2603 | .unwrap(); | ||
| 2604 | let mut remote = Some(Busy { | ||
| 2605 | server: Server::new(&source), | ||
| 2606 | reads: 0, | ||
| 2607 | writes: 0, | ||
| 2608 | }); | ||
| 2609 | let (tx, rx) = mpsc::channel(); | ||
| 2610 | let worker = cache | ||
| 2611 | .start_sync( | ||
| 2612 | Duration::from_millis(1), | ||
| 2613 | move || Ok(remote.take().expect("Contention caused a reconnect")), | ||
| 2614 | move |result| { | ||
| 2615 | tx.send(result.as_ref().copied().map_err(|error| error.to_string())) | ||
| 2616 | .unwrap(); | ||
| 2617 | }, | ||
| 2618 | ) | ||
| 2619 | .unwrap(); | ||
| 2620 | for _ in 0..4 { | ||
| 2621 | assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); | ||
| 2622 | } | ||
| 2623 | assert!( | ||
| 2624 | matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | ||
| 2625 | ); | ||
| 2626 | worker.stop().unwrap(); | ||
| 2627 | assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc"); | ||
| 2628 | assert!(cache.pending().unwrap().is_empty()); | ||
| 2629 | } | ||
| 2630 | #[test] | ||
| 2631 | fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() { | ||
| 2632 | struct BusyOnce(Server); | ||
| 2633 | impl Remote for BusyOnce { | ||
| 2634 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 2635 | self.0.read() | ||
| 2636 | } | ||
| 2637 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 2638 | let server = &mut self.0; | ||
| 2639 | if server.publications == 0 { | ||
| 2640 | server.publications += 1; | ||
| 2641 | return Err(CommitError { | ||
| 2642 | state: CommitState::NotCommitted, | ||
| 2643 | error: io::ErrorKind::ResourceBusy.into(), | ||
| 2644 | }); | ||
| 2645 | } | ||
| 2646 | server.publish(edit) | ||
| 2647 | } | ||
| 2648 | fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { | ||
| 2649 | self.0.confirm(source) | ||
| 2650 | } | ||
| 2651 | } | ||
| 2652 | let dir = tempfile::tempdir().unwrap(); | ||
| 2653 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2654 | let (sid, oid, _) = text(&source); | ||
| 2655 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2656 | let first = cache | ||
| 2657 | .edit_text(&source, sid, oid, 0..0, "L ") | ||
| 2658 | .unwrap() | ||
| 2659 | .unwrap(); | ||
| 2660 | let mut remote = Some(BusyOnce(Server::new(&source))); | ||
| 2661 | let observed = Arc::clone(&cache); | ||
| 2662 | let (tx, rx) = mpsc::channel(); | ||
| 2663 | let mut failed = false; | ||
| 2664 | let worker = cache | ||
| 2665 | .start_sync( | ||
| 2666 | Duration::from_secs(3600), | ||
| 2667 | move || Ok(remote.take().expect("Contention must retain the session")), | ||
| 2668 | move |result| match result { | ||
| 2669 | Err(Error::Remote(error)) if error.state == CommitState::NotCommitted => { | ||
| 2670 | assert!(!failed); | ||
| 2671 | failed = true; | ||
| 2672 | let source = observed.snapshot().unwrap(); | ||
| 2673 | let second = observed | ||
| 2674 | .edit_text(&source, sid, oid, 0..0, "Q ") | ||
| 2675 | .unwrap() | ||
| 2676 | .unwrap(); | ||
| 2677 | tx.send((second, None)).unwrap(); | ||
| 2678 | } | ||
| 2679 | Ok(Some((id, status))) => tx.send((*id, Some(*status))).unwrap(), | ||
| 2680 | Ok(None) => {} | ||
| 2681 | other => panic!("Unexpected worker result: {other:?}"), | ||
| 2682 | }, | ||
| 2683 | ) | ||
| 2684 | .unwrap(); | ||
| 2685 | let (second, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2686 | assert_eq!(status, None); | ||
| 2687 | for expected in [first, second] { | ||
| 2688 | let (id, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2689 | assert_eq!(id, expected); | ||
| 2690 | assert!(matches!(status, Some(EditStatus::Published { .. }))); | ||
| 2691 | } | ||
| 2692 | worker.stop().unwrap(); | ||
| 2693 | assert!(cache.pending().unwrap().is_empty()); | ||
| 2694 | assert_eq!(text(&cache.snapshot().unwrap()).2, "Q L abc"); | ||
| 2695 | } | ||
| 2696 | } | ||
| 2697 | |||
| 2698 | #[test] | ||
| 2699 | fn offline_insertions_survive_reopen_rebase_and_dependent_text_edits() { | ||
| 2700 | use onestore::Insertion; | ||
| 2701 | let dir = tempfile::tempdir().unwrap(); | ||
| 2702 | let path = dir.path().join("insert.sqlite"); | ||
| 2703 | let source = onestore::create_section("insert.one", "Original", "Author").unwrap(); | ||
| 2704 | let (sid, original, _) = text(&source); | ||
| 2705 | let store = Store::parse(&source).unwrap(); | ||
| 2706 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 2707 | let doc = Document::parse(&index).unwrap(); | ||
| 2708 | let (_, page) = doc.pages().unwrap()[0]; | ||
| 2709 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 2710 | let outline = Insertion::outline(page, 72.0, 144.0, "Offline outline", "Offline author") | ||
| 2711 | .unwrap() | ||
| 2712 | .with_formatting(0..7, &[onestore::TextAttribute::Italic(true)]) | ||
| 2713 | .unwrap(); | ||
| 2714 | let first = cache.insert(&source, sid, &outline).unwrap().unwrap(); | ||
| 2715 | let snapshot = cache.snapshot().unwrap(); | ||
| 2716 | let paragraph = Insertion::paragraph( | ||
| 2717 | outline.object(), | ||
| 2718 | None, | ||
| 2719 | "Offline paragraph 🦀", | ||
| 2720 | "Offline author", | ||
| 2721 | ) | ||
| 2722 | .unwrap() | ||
| 2723 | .with_formatting(8..17, &[onestore::TextAttribute::Bold(true)]) | ||
| 2724 | .unwrap(); | ||
| 2725 | let second = cache.insert(&snapshot, sid, &paragraph).unwrap().unwrap(); | ||
| 2726 | let third = cache | ||
| 2727 | .edit_text( | ||
| 2728 | &cache.snapshot().unwrap(), | ||
| 2729 | sid, | ||
| 2730 | paragraph.text_object(), | ||
| 2731 | 0..0, | ||
| 2732 | "Edited ", | ||
| 2733 | ) | ||
| 2734 | .unwrap() | ||
| 2735 | .unwrap(); | ||
| 2736 | let local = cache.snapshot().unwrap(); | ||
| 2737 | let pending = cache.pending().unwrap(); | ||
| 2738 | drop(cache); | ||
| 2739 | let cache = Replica::open(&path).unwrap(); | ||
| 2740 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 2741 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 2742 | let remote = onestore::replace_text(&source, sid, original, 0..0, "Remote ").unwrap(); | ||
| 2743 | let mut server = Server::new(&remote); | ||
| 2744 | for id in [first, second, third] { | ||
| 2745 | assert!( | ||
| 2746 | matches!(cache.sync_once(&mut server).unwrap(),Some((observed,EditStatus::Published{..})) if observed==id) | ||
| 2747 | ); | ||
| 2748 | } | ||
| 2749 | assert_eq!(server.publications, 3); | ||
| 2750 | assert!(cache.pending().unwrap().is_empty()); | ||
| 2751 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 2752 | let store = Store::parse(&server.durable).unwrap(); | ||
| 2753 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 2754 | let doc = Document::parse(&index).unwrap(); | ||
| 2755 | let s = &doc.spaces[&sid]; | ||
| 2756 | let v = &s.revisions[&s.contexts[&ExGuid::default()]]; | ||
| 2757 | for (id, wanted) in [ | ||
| 2758 | (original, "Remote Original"), | ||
| 2759 | (outline.text_object(), "Offline outline"), | ||
| 2760 | (paragraph.text_object(), "Edited Offline paragraph 🦀"), | ||
| 2761 | ] { | ||
| 2762 | assert!(matches!(&v.nodes[&id].kind,Kind::RichText{text,..} if text==wanted)); | ||
| 2763 | } | ||
| 2764 | let outline_runs = v.text_runs(outline.text_object()).unwrap(); | ||
| 2765 | assert_eq!(outline_runs[0].text, "Offline"); | ||
| 2766 | assert_eq!(outline_runs[0].format.italic, Some(true)); | ||
| 2767 | let runs = v.text_runs(paragraph.text_object()).unwrap(); | ||
| 2768 | assert_eq!(runs[1].text, "paragraph"); | ||
| 2769 | assert_eq!(runs[1].format.bold, Some(true)); | ||
| 2770 | assert_eq!( | ||
| 2771 | v.nodes[&outline.object()].children.last(), | ||
| 2772 | Some(&paragraph.object()) | ||
| 2773 | ); | ||
| 2774 | } | ||
| 2775 | |||
| 2776 | #[test] | ||
| 2777 | fn uncertain_insertions_reconcile_the_original_revision_without_duplicate_objects() { | ||
| 2778 | use onestore::Insertion; | ||
| 2779 | for fault in [ | ||
| 2780 | Fault::Before, | ||
| 2781 | Fault::UnknownBefore, | ||
| 2782 | Fault::UnknownAfter, | ||
| 2783 | Fault::PanicBefore, | ||
| 2784 | Fault::PanicAfter, | ||
| 2785 | Fault::Committed, | ||
| 2786 | ] { | ||
| 2787 | let dir = tempfile::tempdir().unwrap(); | ||
| 2788 | let path = dir.path().join("uncertain-insert.sqlite"); | ||
| 2789 | let source = onestore::create_section("insert.one", "Original", "Author").unwrap(); | ||
| 2790 | let store = Store::parse(&source).unwrap(); | ||
| 2791 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 2792 | let doc = Document::parse(&index).unwrap(); | ||
| 2793 | let (sid, page) = doc.pages().unwrap()[0]; | ||
| 2794 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 2795 | let insertion = Insertion::outline(page, 144.0, 144.0, "Uncertain insertion", "Author") | ||
| 2796 | .unwrap() | ||
| 2797 | .with_formatting(0..9, &[onestore::TextAttribute::Bold(true)]) | ||
| 2798 | .unwrap() | ||
| 2799 | .with_formatting(10..19, &[onestore::TextAttribute::Italic(true)]) | ||
| 2800 | .unwrap(); | ||
| 2801 | let id = cache.insert(&source, sid, &insertion).unwrap().unwrap(); | ||
| 2802 | let local = cache.snapshot().unwrap(); | ||
| 2803 | let mut server = Server::new(&source); | ||
| 2804 | server.fault = fault; | ||
| 2805 | let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | ||
| 2806 | cache.sync_once(&mut server) | ||
| 2807 | })); | ||
| 2808 | drop(cache); | ||
| 2809 | let cache = Replica::open(&path).unwrap(); | ||
| 2810 | assert_eq!(server.publications, 1); | ||
| 2811 | if matches!(fault, Fault::UnknownBefore | Fault::PanicBefore) { | ||
| 2812 | let status = cache.status(id).unwrap(); | ||
| 2813 | assert!(matches!( | ||
| 2814 | status, | ||
| 2815 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 2816 | )); | ||
| 2817 | for _ in 0..5 { | ||
| 2818 | assert_eq!( | ||
| 2819 | cache.sync_once(&mut server).unwrap(), | ||
| 2820 | status.map(|state| (id, state)) | ||
| 2821 | ); | ||
| 2822 | } | ||
| 2823 | assert_eq!(server.publications, 1); | ||
| 2824 | assert_eq!(server.durable, source); | ||
| 2825 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 2826 | } else { | ||
| 2827 | if !matches!(fault, Fault::Committed) { | ||
| 2828 | assert!(matches!( | ||
| 2829 | cache.sync_once(&mut server).unwrap(), | ||
| 2830 | Some((_, EditStatus::Published { .. })) | ||
| 2831 | )); | ||
| 2832 | } | ||
| 2833 | assert_eq!( | ||
| 2834 | server.publications, | ||
| 2835 | if matches!(fault, Fault::Before) { 2 } else { 1 } | ||
| 2836 | ); | ||
| 2837 | assert_eq!( | ||
| 2838 | server.confirmations, | ||
| 2839 | usize::from(matches!(fault, Fault::UnknownAfter | Fault::PanicAfter)) | ||
| 2840 | ); | ||
| 2841 | let store = Store::parse(&server.durable).unwrap(); | ||
| 2842 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 2843 | let doc = Document::parse(&index).unwrap(); | ||
| 2844 | let s = &doc.spaces[&sid]; | ||
| 2845 | let v = &s.revisions[&s.contexts[&ExGuid::default()]]; | ||
| 2846 | assert_eq!(v.nodes.values().filter(|node|matches!(&node.kind,Kind::RichText{text,..} if text=="Uncertain insertion")).count(),1); | ||
| 2847 | assert!(v.nodes.contains_key(&insertion.object())); | ||
| 2848 | let runs = v.text_runs(insertion.text_object()).unwrap(); | ||
| 2849 | assert_eq!(runs.len(), 3); | ||
| 2850 | assert_eq!(runs[0].text, "Uncertain"); | ||
| 2851 | assert_eq!(runs[0].format.bold, Some(true)); | ||
| 2852 | assert_eq!(runs[1].text, " "); | ||
| 2853 | assert_ne!(runs[1].format.bold, Some(true)); | ||
| 2854 | assert_ne!(runs[1].format.italic, Some(true)); | ||
| 2855 | assert_eq!(runs[2].text, "insertion"); | ||
| 2856 | assert_eq!(runs[2].format.italic, Some(true)); | ||
| 2857 | assert!(cache.pending().unwrap().is_empty()); | ||
| 2858 | } | ||
| 2859 | } | ||
| 2860 | } | ||
| 2861 | |||
| 2862 | #[test] | ||
| 2863 | fn cross_run_text_rebases_preserve_remote_styles_and_survive_lost_replies() { | ||
| 2864 | use onestore::TextAttribute as A; | ||
| 2865 | for fault in [Fault::None, Fault::UnknownAfter, Fault::PanicAfter] { | ||
| 2866 | let directory = tempfile::tempdir().unwrap(); | ||
| 2867 | let path = directory.path().join("cross-run.sqlite"); | ||
| 2868 | let plain = onestore::create_section("cross.one", "ab🦀cde\u{301}fg", "Author").unwrap(); | ||
| 2869 | let (sid, oid, _) = text(&plain); | ||
| 2870 | let bold = PreparedEdit::format(&plain, sid, oid, 1..4, &[A::Bold(true)]).unwrap(); | ||
| 2871 | let source = PreparedEdit::format(bold.as_bytes(), sid, oid, 4..7, &[A::Italic(true)]) | ||
| 2872 | .unwrap() | ||
| 2873 | .as_bytes() | ||
| 2874 | .to_vec(); | ||
| 2875 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 2876 | let first = cache | ||
| 2877 | .edit_text(&source, sid, oid, 2..7, "日本語") | ||
| 2878 | .unwrap() | ||
| 2879 | .unwrap(); | ||
| 2880 | let local = cache.snapshot().unwrap(); | ||
| 2881 | let second = cache | ||
| 2882 | .edit_text(&local, sid, oid, 3..4, "🐈") | ||
| 2883 | .unwrap() | ||
| 2884 | .unwrap(); | ||
| 2885 | let pending = cache.pending().unwrap(); | ||
| 2886 | drop(cache); | ||
| 2887 | let cache = Replica::open(&path).unwrap(); | ||
| 2888 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 2889 | let prefix = PreparedEdit::text(&source, sid, oid, 0..0, "Prefix ").unwrap(); | ||
| 2890 | let remote = | ||
| 2891 | PreparedEdit::format(prefix.as_bytes(), sid, oid, 9..11, &[A::Underline(true)]) | ||
| 2892 | .unwrap(); | ||
| 2893 | let mut server = Server::new(remote.as_bytes()); | ||
| 2894 | server.fault = fault; | ||
| 2895 | let attempted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | ||
| 2896 | cache.sync_once(&mut server) | ||
| 2897 | })); | ||
| 2898 | if matches!(fault, Fault::None) { | ||
| 2899 | assert!( | ||
| 2900 | matches!(attempted.unwrap().unwrap(), Some((id, EditStatus::Published { .. })) if id == first) | ||
| 2901 | ); | ||
| 2902 | } else { | ||
| 2903 | assert!(matches!( | ||
| 2904 | cache.status(first).unwrap(), | ||
| 2905 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 2906 | )); | ||
| 2907 | } | ||
| 2908 | drop(cache); | ||
| 2909 | let cache = Replica::open(&path).unwrap(); | ||
| 2910 | while let Some((_, status)) = cache.sync_once(&mut server).unwrap() { | ||
| 2911 | assert!(matches!(status, EditStatus::Published { .. })); | ||
| 2912 | } | ||
| 2913 | assert_eq!(server.publications, 2); | ||
| 2914 | assert!(matches!( | ||
| 2915 | cache.status(second).unwrap(), | ||
| 2916 | Some(EditStatus::Published { .. }) | ||
| 2917 | )); | ||
| 2918 | assert_eq!(text(&server.durable).2, "Prefix ab日🐈語\u{301}fg"); | ||
| 2919 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 2920 | let store = Store::parse(&server.durable).unwrap(); | ||
| 2921 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 2922 | let document = Document::parse(&index).unwrap(); | ||
| 2923 | let space = &document.spaces[&sid]; | ||
| 2924 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 2925 | let runs = revision.text_runs(oid).unwrap(); | ||
| 2926 | let replacement = runs.iter().find(|run| run.text == "日🐈語").unwrap(); | ||
| 2927 | assert_eq!(replacement.format.bold, Some(true)); | ||
| 2928 | assert_eq!(replacement.format.underline, Some(true)); | ||
| 2929 | assert_ne!(replacement.format.italic, Some(true)); | ||
| 2930 | let suffix = runs.last().unwrap(); | ||
| 2931 | assert_eq!(suffix.text, "\u{301}fg"); | ||
| 2932 | assert_ne!(suffix.format.bold, Some(true)); | ||
| 2933 | assert_ne!(suffix.format.underline, Some(true)); | ||
| 2934 | assert_ne!(suffix.format.italic, Some(true)); | ||
| 2935 | } | ||
| 2936 | } | ||
| 2937 | |||
| 2938 | #[test] | ||
| 2939 | fn offline_formatting_rebases_text_and_merges_independent_attributes() { | ||
| 2940 | use onestore::TextAttribute as A; | ||
| 2941 | let directory = tempfile::tempdir().unwrap(); | ||
| 2942 | let path = directory.path().join("format.sqlite"); | ||
| 2943 | let source = onestore::create_section("format.one", "ab🦀cd", "Author").unwrap(); | ||
| 2944 | let (sid, id, _) = text(&source); | ||
| 2945 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 2946 | let local = cache | ||
| 2947 | .format(&source, sid, id, 2..4, &[A::Bold(true)]) | ||
| 2948 | .unwrap() | ||
| 2949 | .unwrap(); | ||
| 2950 | let pending = cache.pending().unwrap(); | ||
| 2951 | drop(cache); | ||
| 2952 | let cache = Replica::open(&path).unwrap(); | ||
| 2953 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 2954 | let moved = PreparedEdit::text(&source, sid, id, 0..0, "Prefix ").unwrap(); | ||
| 2955 | let styled = | ||
| 2956 | PreparedEdit::format(moved.as_bytes(), sid, id, 9..11, &[A::Italic(true)]).unwrap(); | ||
| 2957 | let mut server = Server::new(styled.as_bytes()); | ||
| 2958 | assert!( | ||
| 2959 | matches!(cache.sync_once(&mut server).unwrap(),Some((observed,EditStatus::Published{..}))if observed==local) | ||
| 2960 | ); | ||
| 2961 | let store = Store::parse(&server.durable).unwrap(); | ||
| 2962 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 2963 | let doc = Document::parse(&index).unwrap(); | ||
| 2964 | let space = &doc.spaces[&sid]; | ||
| 2965 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 2966 | let runs = view.text_runs(id).unwrap(); | ||
| 2967 | let selected = runs.iter().find(|r| r.text == "🦀").unwrap(); | ||
| 2968 | assert_eq!(selected.format.bold, Some(true)); | ||
| 2969 | assert_eq!(selected.format.italic, Some(true)); | ||
| 2970 | assert_eq!( | ||
| 2971 | runs.iter().map(|r| r.text).collect::<String>(), | ||
| 2972 | "Prefix ab🦀cd" | ||
| 2973 | ); | ||
| 2974 | } | ||
| 2975 | |||
| 2976 | #[test] | ||
| 2977 | fn competing_font_changes_remain_preserved_conflicts() { | ||
| 2978 | use onestore::TextAttribute as A; | ||
| 2979 | let directory = tempfile::tempdir().unwrap(); | ||
| 2980 | let path = directory.path().join("conflict.sqlite"); | ||
| 2981 | let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); | ||
| 2982 | let (sid, id, _) = text(&source); | ||
| 2983 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 2984 | let local_id = cache | ||
| 2985 | .format(&source, sid, id, 1..5, &[A::FontSize(14.0)]) | ||
| 2986 | .unwrap() | ||
| 2987 | .unwrap(); | ||
| 2988 | let local = cache.snapshot().unwrap(); | ||
| 2989 | let pending = cache.pending().unwrap(); | ||
| 2990 | let remote = PreparedEdit::format(&source, sid, id, 2..4, &[A::FontSize(18.0)]).unwrap(); | ||
| 2991 | let mut server = Server::new(remote.as_bytes()); | ||
| 2992 | for _ in 0..3 { | ||
| 2993 | assert_eq!( | ||
| 2994 | cache.sync_once(&mut server).unwrap(), | ||
| 2995 | Some(( | ||
| 2996 | local_id, | ||
| 2997 | EditStatus::Conflict(ConflictKind::FormattingChanged) | ||
| 2998 | )) | ||
| 2999 | ); | ||
| 3000 | } | ||
| 3001 | assert_eq!(server.publications, 0); | ||
| 3002 | assert_eq!(server.confirmations, 0); | ||
| 3003 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3004 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 3005 | drop(cache); | ||
| 3006 | let cache = Replica::open(&path).unwrap(); | ||
| 3007 | assert_eq!( | ||
| 3008 | cache.status(local_id).unwrap(), | ||
| 3009 | Some(EditStatus::Conflict(ConflictKind::FormattingChanged)) | ||
| 3010 | ); | ||
| 3011 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3012 | } | ||
| 3013 | |||
| 3014 | #[test] | ||
| 3015 | fn independently_satisfied_formatting_requires_confirmation_before_a_receipt() { | ||
| 3016 | use onestore::TextAttribute as A; | ||
| 3017 | let directory = tempfile::tempdir().unwrap(); | ||
| 3018 | let path = directory.path().join("satisfied.sqlite"); | ||
| 3019 | let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); | ||
| 3020 | let (sid, id, _) = text(&source); | ||
| 3021 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3022 | let local_id = cache | ||
| 3023 | .format(&source, sid, id, 1..5, &[A::Bold(true)]) | ||
| 3024 | .unwrap() | ||
| 3025 | .unwrap(); | ||
| 3026 | let remote = PreparedEdit::format(&source, sid, id, 1..5, &[A::Bold(true)]).unwrap(); | ||
| 3027 | let mut server = Server::new(remote.as_bytes()); | ||
| 3028 | server.durable = source.clone(); | ||
| 3029 | server.fault = Fault::Confirm; | ||
| 3030 | assert!( | ||
| 3031 | matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown) | ||
| 3032 | ); | ||
| 3033 | assert_eq!(cache.status(local_id).unwrap(), Some(EditStatus::Pending)); | ||
| 3034 | assert_eq!(server.publications, 0); | ||
| 3035 | assert_eq!(server.durable, source); | ||
| 3036 | drop(cache); | ||
| 3037 | let cache = Replica::open(&path).unwrap(); | ||
| 3038 | assert!( | ||
| 3039 | matches!(cache.sync_once(&mut server).unwrap(),Some((id,EditStatus::Published{..}))if id==local_id) | ||
| 3040 | ); | ||
| 3041 | assert_eq!(server.publications, 0); | ||
| 3042 | assert_eq!(server.confirmations, 2); | ||
| 3043 | assert_ne!(server.durable, source); | ||
| 3044 | assert!(cache.pending().unwrap().is_empty()); | ||
| 3045 | } | ||
| 3046 | |||
| 3047 | #[test] | ||
| 3048 | fn retired_format_attempts_require_the_complete_durable_effect_without_replay() { | ||
| 3049 | use onestore::TextAttribute as A; | ||
| 3050 | for (complete, fault) in [ | ||
| 3051 | (true, Fault::None), | ||
| 3052 | (true, Fault::Confirm), | ||
| 3053 | (true, Fault::ConfirmCommitted), | ||
| 3054 | (false, Fault::None), | ||
| 3055 | ] { | ||
| 3056 | let directory = tempfile::tempdir().unwrap(); | ||
| 3057 | let path = directory.path().join("retired-format.sqlite"); | ||
| 3058 | let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); | ||
| 3059 | let (sid, object, _) = text(&source); | ||
| 3060 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3061 | let id = cache | ||
| 3062 | .format( | ||
| 3063 | &source, | ||
| 3064 | sid, | ||
| 3065 | object, | ||
| 3066 | 1..5, | ||
| 3067 | &[A::Bold(true), A::FontSize(18.0)], | ||
| 3068 | ) | ||
| 3069 | .unwrap() | ||
| 3070 | .unwrap(); | ||
| 3071 | let local = cache.snapshot().unwrap(); | ||
| 3072 | let mut server = Server::new(&source); | ||
| 3073 | server.fault = Fault::UnknownAfter; | ||
| 3074 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 3075 | let attempted = cache.status(id).unwrap(); | ||
| 3076 | let Some(EditStatus::AwaitingConfirmation { revision: retired }) = attempted else { | ||
| 3077 | panic!() | ||
| 3078 | }; | ||
| 3079 | drop(cache); | ||
| 3080 | let prefix = PreparedEdit::text(&source, sid, object, 0..0, "prefix ").unwrap(); | ||
| 3081 | let mut attributes = vec![A::Bold(true), A::Italic(true)]; | ||
| 3082 | if complete { | ||
| 3083 | attributes.push(A::FontSize(18.0)); | ||
| 3084 | } | ||
| 3085 | server.visible = PreparedEdit::format(prefix.as_bytes(), sid, object, 8..12, &attributes) | ||
| 3086 | .unwrap() | ||
| 3087 | .as_bytes() | ||
| 3088 | .to_vec(); | ||
| 3089 | let store = Store::parse(&server.visible).unwrap(); | ||
| 3090 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3091 | let current = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; | ||
| 3092 | assert!(!index.spaces[&sid].revisions.contains_key(&retired)); | ||
| 3093 | let cache = Replica::open(&path).unwrap(); | ||
| 3094 | server.fault = fault; | ||
| 3095 | let result = cache.sync_once(&mut server); | ||
| 3096 | if !complete { | ||
| 3097 | assert_eq!(result.unwrap(), attempted.map(|s| (id, s))); | ||
| 3098 | assert_eq!(server.confirmations, 0); | ||
| 3099 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3100 | assert_eq!(server.durable, source); | ||
| 3101 | } else { | ||
| 3102 | if matches!(fault, Fault::Confirm) { | ||
| 3103 | assert!(matches!(result, Err(Error::Remote(e)) if e.state == CommitState::Unknown)); | ||
| 3104 | assert_eq!(cache.status(id).unwrap(), attempted); | ||
| 3105 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3106 | assert_eq!(server.durable, source); | ||
| 3107 | let complete = server.visible.clone(); | ||
| 3108 | server.visible = | ||
| 3109 | PreparedEdit::format(&complete, sid, object, 8..12, &[A::Bold(false)]) | ||
| 3110 | .unwrap() | ||
| 3111 | .as_bytes() | ||
| 3112 | .to_vec(); | ||
| 3113 | assert_eq!( | ||
| 3114 | cache.sync_once(&mut server).unwrap(), | ||
| 3115 | attempted.map(|s| (id, s)) | ||
| 3116 | ); | ||
| 3117 | assert_eq!(server.confirmations, 1); | ||
| 3118 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3119 | server.visible = complete; | ||
| 3120 | cache.sync_once(&mut server).unwrap(); | ||
| 3121 | } else if matches!(fault, Fault::ConfirmCommitted) { | ||
| 3122 | assert!( | ||
| 3123 | matches!(result, Err(Error::Remote(e)) if e.state == CommitState::Committed) | ||
| 3124 | ); | ||
| 3125 | } else { | ||
| 3126 | assert_eq!( | ||
| 3127 | result.unwrap(), | ||
| 3128 | Some((id, EditStatus::Published { revision: current })) | ||
| 3129 | ); | ||
| 3130 | } | ||
| 3131 | assert_ne!(current, retired); | ||
| 3132 | assert_eq!( | ||
| 3133 | cache.status(id).unwrap(), | ||
| 3134 | Some(EditStatus::Published { revision: current }) | ||
| 3135 | ); | ||
| 3136 | assert!(cache.pending().unwrap().is_empty()); | ||
| 3137 | assert_eq!(text(&server.durable).2, "prefix abcdef"); | ||
| 3138 | assert_ne!(server.durable, source); | ||
| 3139 | drop(cache); | ||
| 3140 | let cache = Replica::open(&path).unwrap(); | ||
| 3141 | assert_eq!( | ||
| 3142 | cache.status(id).unwrap(), | ||
| 3143 | Some(EditStatus::Published { revision: current }) | ||
| 3144 | ); | ||
| 3145 | } | ||
| 3146 | assert_eq!(server.publications, 1); | ||
| 3147 | } | ||
| 3148 | } | ||
| 3149 | |||
| 3150 | #[test] | ||
| 3151 | fn retired_text_with_equal_plaintext_but_a_different_surviving_run_remains_uncertain() { | ||
| 3152 | use onestore::TextAttribute; | ||
| 3153 | |||
| 3154 | let directory = tempfile::tempdir().unwrap(); | ||
| 3155 | let path = directory.path().join("repeated.sqlite"); | ||
| 3156 | let plain = onestore::create_section("repeated.one", "aa", "Fixture").unwrap(); | ||
| 3157 | let (space, object, _) = text(&plain); | ||
| 3158 | let source = PreparedEdit::format(&plain, space, object, 0..1, &[TextAttribute::Bold(true)]) | ||
| 3159 | .unwrap() | ||
| 3160 | .as_bytes() | ||
| 3161 | .to_vec(); | ||
| 3162 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3163 | let id = cache | ||
| 3164 | .edit_text(&source, space, object, 0..1, "") | ||
| 3165 | .unwrap() | ||
| 3166 | .unwrap(); | ||
| 3167 | let local = cache.snapshot().unwrap(); | ||
| 3168 | let mut server = Server::new(&source); | ||
| 3169 | server.fault = Fault::UnknownAfter; | ||
| 3170 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 3171 | let attempt = cache.status(id).unwrap(); | ||
| 3172 | server.visible = onestore::replace_text(&source, space, object, 1..2, "").unwrap(); | ||
| 3173 | assert_eq!(text(&server.visible).2, text(&local).2); | ||
| 3174 | let bold = |bytes: &[u8]| { | ||
| 3175 | let store = Store::parse(bytes).unwrap(); | ||
| 3176 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3177 | let document = Document::parse(&index).unwrap(); | ||
| 3178 | let space = &document.spaces[&space]; | ||
| 3179 | space.revisions[&space.contexts[&ExGuid::default()]] | ||
| 3180 | .text_runs(object) | ||
| 3181 | .unwrap() | ||
| 3182 | .into_iter() | ||
| 3183 | .find(|run| !run.text.is_empty()) | ||
| 3184 | .unwrap() | ||
| 3185 | .format | ||
| 3186 | .bold | ||
| 3187 | }; | ||
| 3188 | assert_eq!(bold(&server.visible), Some(true)); | ||
| 3189 | assert_ne!(bold(&server.visible), bold(&local)); | ||
| 3190 | drop(cache); | ||
| 3191 | let cache = Replica::open(&path).unwrap(); | ||
| 3192 | assert_eq!( | ||
| 3193 | cache.sync_once(&mut server).unwrap(), | ||
| 3194 | attempt.map(|state| (id, state)) | ||
| 3195 | ); | ||
| 3196 | assert_eq!(server.publications, 1); | ||
| 3197 | assert_eq!(server.confirmations, 0); | ||
| 3198 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3199 | } | ||
| 3200 | |||
| 3201 | #[test] | ||
| 3202 | fn uncertain_formatting_keeps_the_original_attempt_and_never_replays() { | ||
| 3203 | use onestore::TextAttribute as A; | ||
| 3204 | for fault in [Fault::UnknownBefore, Fault::UnknownAfter] { | ||
| 3205 | let directory = tempfile::tempdir().unwrap(); | ||
| 3206 | let path = directory.path().join("unknown-format.sqlite"); | ||
| 3207 | let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); | ||
| 3208 | let (sid, id, _) = text(&source); | ||
| 3209 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3210 | let local_id = cache | ||
| 3211 | .format(&source, sid, id, 1..5, &[A::Bold(true)]) | ||
| 3212 | .unwrap() | ||
| 3213 | .unwrap(); | ||
| 3214 | let mut server = Server::new(&source); | ||
| 3215 | server.fault = fault; | ||
| 3216 | assert!( | ||
| 3217 | matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown) | ||
| 3218 | ); | ||
| 3219 | let status = cache.status(local_id).unwrap(); | ||
| 3220 | drop(cache); | ||
| 3221 | let cache = Replica::open(&path).unwrap(); | ||
| 3222 | if matches!(fault, Fault::UnknownBefore) { | ||
| 3223 | for _ in 0..4 { | ||
| 3224 | assert_eq!( | ||
| 3225 | cache.sync_once(&mut server).unwrap(), | ||
| 3226 | status.map(|s| (local_id, s)) | ||
| 3227 | ); | ||
| 3228 | } | ||
| 3229 | assert_eq!(server.confirmations, 0); | ||
| 3230 | } else { | ||
| 3231 | assert!(matches!( | ||
| 3232 | cache.sync_once(&mut server).unwrap(), | ||
| 3233 | Some((_, EditStatus::Published { .. })) | ||
| 3234 | )); | ||
| 3235 | assert_eq!(server.confirmations, 1); | ||
| 3236 | } | ||
| 3237 | assert_eq!(server.publications, 1); | ||
| 3238 | } | ||
| 3239 | } | ||
| 3240 | |||
| 3241 | #[test] | ||
| 3242 | fn reviewed_format_conflicts_preserve_dependent_edits_and_recheck_later_remote_changes() { | ||
| 3243 | use onestore::TextAttribute as A; | ||
| 3244 | for changed_again in [false, true] { | ||
| 3245 | let directory = tempfile::tempdir().unwrap(); | ||
| 3246 | let path = directory.path().join("review-format.sqlite"); | ||
| 3247 | let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); | ||
| 3248 | let (sid, id, _) = text(&source); | ||
| 3249 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3250 | let first = cache | ||
| 3251 | .format(&source, sid, id, 1..5, &[A::FontSize(14.0)]) | ||
| 3252 | .unwrap() | ||
| 3253 | .unwrap(); | ||
| 3254 | let second = cache | ||
| 3255 | .edit_text(&cache.snapshot().unwrap(), sid, id, 2..2, "X") | ||
| 3256 | .unwrap() | ||
| 3257 | .unwrap(); | ||
| 3258 | let local = cache.snapshot().unwrap(); | ||
| 3259 | let pending = cache.pending().unwrap(); | ||
| 3260 | let remote = PreparedEdit::format(&source, sid, id, 1..5, &[A::FontSize(18.0)]).unwrap(); | ||
| 3261 | let mut server = Server::new(remote.as_bytes()); | ||
| 3262 | assert_eq!( | ||
| 3263 | cache.sync_once(&mut server).unwrap(), | ||
| 3264 | Some((first, EditStatus::Conflict(ConflictKind::FormattingChanged))) | ||
| 3265 | ); | ||
| 3266 | assert!( | ||
| 3267 | matches!(cache.rebase_conflict(first,&source,remote.as_bytes(),1..5),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy) | ||
| 3268 | ); | ||
| 3269 | cache | ||
| 3270 | .rebase_conflict(first, &local, remote.as_bytes(), 1..5) | ||
| 3271 | .unwrap(); | ||
| 3272 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3273 | assert_eq!(cache.pending().unwrap()[1], pending[1]); | ||
| 3274 | drop(cache); | ||
| 3275 | let cache = Replica::open(&path).unwrap(); | ||
| 3276 | if changed_again { | ||
| 3277 | let newer = | ||
| 3278 | PreparedEdit::format(&server.visible, sid, id, 1..5, &[A::FontSize(20.0)]).unwrap(); | ||
| 3279 | server = Server::new(newer.as_bytes()); | ||
| 3280 | assert_eq!( | ||
| 3281 | cache.sync_once(&mut server).unwrap(), | ||
| 3282 | Some((first, EditStatus::Conflict(ConflictKind::FormattingChanged))) | ||
| 3283 | ); | ||
| 3284 | assert_eq!(server.publications, 0); | ||
| 3285 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3286 | } else { | ||
| 3287 | for expected in [first, second] { | ||
| 3288 | assert!( | ||
| 3289 | matches!(cache.sync_once(&mut server).unwrap(),Some((id,EditStatus::Published{..}))if id==expected) | ||
| 3290 | ); | ||
| 3291 | } | ||
| 3292 | assert_eq!(text(&server.durable).2, "abXcdef"); | ||
| 3293 | assert!(cache.pending().unwrap().is_empty()); | ||
| 3294 | } | ||
| 3295 | } | ||
| 3296 | } | ||
| 3297 | |||
| 3298 | #[test] | ||
| 3299 | fn superscript_reconciliation_checks_the_implicit_subscript_change() { | ||
| 3300 | use onestore::TextAttribute as A; | ||
| 3301 | let directory = tempfile::tempdir().unwrap(); | ||
| 3302 | let source = onestore::create_section("script.one", "abc", "Author").unwrap(); | ||
| 3303 | let (sid, id, _) = text(&source); | ||
| 3304 | let cache = Replica::create(directory.path().join("script.sqlite"), &source).unwrap(); | ||
| 3305 | let local = cache | ||
| 3306 | .format(&source, sid, id, 0..3, &[A::Superscript(true)]) | ||
| 3307 | .unwrap() | ||
| 3308 | .unwrap(); | ||
| 3309 | let remote = PreparedEdit::format(&source, sid, id, 0..3, &[A::Subscript(true)]).unwrap(); | ||
| 3310 | let mut server = Server::new(remote.as_bytes()); | ||
| 3311 | assert_eq!( | ||
| 3312 | cache.sync_once(&mut server).unwrap(), | ||
| 3313 | Some((local, EditStatus::Conflict(ConflictKind::FormattingChanged))) | ||
| 3314 | ); | ||
| 3315 | assert_eq!(server.publications, 0); | ||
| 3316 | } | ||
| 3317 | |||
| 3318 | #[test] | ||
| 3319 | fn seeded_formatting_reconciliation_matches_a_character_model() { | ||
| 3320 | use onestore::TextAttribute as A; | ||
| 3321 | #[derive(Clone, Debug, PartialEq)] | ||
| 3322 | struct Style { | ||
| 3323 | size: f32, | ||
| 3324 | color: u32, | ||
| 3325 | bold: bool, | ||
| 3326 | italic: bool, | ||
| 3327 | } | ||
| 3328 | let mut conflicts = 0; | ||
| 3329 | let mut published = 0; | ||
| 3330 | let mut satisfied_parts = 0; | ||
| 3331 | for seed in 1_u64..=128 { | ||
| 3332 | let mut random = seed; | ||
| 3333 | let mut next = || { | ||
| 3334 | random = random | ||
| 3335 | .wrapping_mul(6364136223846793005) | ||
| 3336 | .wrapping_add(1442695040888963407); | ||
| 3337 | random >> 32 | ||
| 3338 | }; | ||
| 3339 | let directory = tempfile::tempdir().unwrap(); | ||
| 3340 | let path = directory.path().join("model.sqlite"); | ||
| 3341 | let mut source = | ||
| 3342 | onestore::create_section("model.one", "abcdefghijklmnopqrstuvwxyz012345", "Author") | ||
| 3343 | .unwrap(); | ||
| 3344 | let (sid, object, original_text) = text(&source); | ||
| 3345 | let mut baseline = vec![ | ||
| 3346 | Style { | ||
| 3347 | size: 11.0, | ||
| 3348 | color: 0xff000000, | ||
| 3349 | bold: false, | ||
| 3350 | italic: false | ||
| 3351 | }; | ||
| 3352 | 32 | ||
| 3353 | ]; | ||
| 3354 | for _ in 0..6 { | ||
| 3355 | let start = next() as usize % 32; | ||
| 3356 | let end = start + 1 + next() as usize % (32 - start); | ||
| 3357 | let size = [12.0, 14.0, 18.0][next() as usize % 3]; | ||
| 3358 | let color: u32 = [0xabcdef, 0x987654, 0xff000000][next() as usize % 3]; | ||
| 3359 | let bold = next() % 2 == 0; | ||
| 3360 | source = PreparedEdit::format( | ||
| 3361 | &source, | ||
| 3362 | sid, | ||
| 3363 | object, | ||
| 3364 | start as u32..end as u32, | ||
| 3365 | &[ | ||
| 3366 | A::FontSize(size), | ||
| 3367 | A::Color((color != 0xff000000).then(|| { | ||
| 3368 | let b = color.to_le_bytes(); | ||
| 3369 | [b[0], b[1], b[2]] | ||
| 3370 | })), | ||
| 3371 | A::Bold(bold), | ||
| 3372 | ], | ||
| 3373 | ) | ||
| 3374 | .unwrap() | ||
| 3375 | .as_bytes() | ||
| 3376 | .to_vec(); | ||
| 3377 | for style in &mut baseline[start..end] { | ||
| 3378 | style.size = size; | ||
| 3379 | style.color = color; | ||
| 3380 | style.bold = bold; | ||
| 3381 | } | ||
| 3382 | } | ||
| 3383 | let start = next() as usize % 24; | ||
| 3384 | let end = start + 2 + next() as usize % (31 - start); | ||
| 3385 | let attribute = match seed % 3 { | ||
| 3386 | 0 => A::FontSize(21.0), | ||
| 3387 | 1 => A::Color(Some([0x12, 0x34, 0x56])), | ||
| 3388 | _ => A::Bold(!baseline[start].bold), | ||
| 3389 | }; | ||
| 3390 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3391 | let id = cache | ||
| 3392 | .format( | ||
| 3393 | &source, | ||
| 3394 | sid, | ||
| 3395 | object, | ||
| 3396 | start as u32..end as u32, | ||
| 3397 | std::slice::from_ref(&attribute), | ||
| 3398 | ) | ||
| 3399 | .unwrap() | ||
| 3400 | .unwrap(); | ||
| 3401 | let local = cache.snapshot().unwrap(); | ||
| 3402 | let mut remote = source.clone(); | ||
| 3403 | let mut expected = baseline.clone(); | ||
| 3404 | for step in 0..8 { | ||
| 3405 | let left = next() as usize % 32; | ||
| 3406 | let right = left + 1 + next() as usize % (32 - left); | ||
| 3407 | let update = if step % 3 == 0 { | ||
| 3408 | attribute.clone() | ||
| 3409 | } else { | ||
| 3410 | match next() % 4 { | ||
| 3411 | 0 => A::FontSize([11.0, 14.0, 18.0, 21.0][next() as usize % 4]), | ||
| 3412 | 1 => A::Color(Some([0x99, 0x88, 0x77])), | ||
| 3413 | 2 => A::Bold(next() % 2 == 0), | ||
| 3414 | _ => A::Italic(true), | ||
| 3415 | } | ||
| 3416 | }; | ||
| 3417 | remote = PreparedEdit::format( | ||
| 3418 | &remote, | ||
| 3419 | sid, | ||
| 3420 | object, | ||
| 3421 | left as u32..right as u32, | ||
| 3422 | std::slice::from_ref(&update), | ||
| 3423 | ) | ||
| 3424 | .unwrap() | ||
| 3425 | .as_bytes() | ||
| 3426 | .to_vec(); | ||
| 3427 | for style in &mut expected[left..right] { | ||
| 3428 | match update { | ||
| 3429 | A::FontSize(value) => style.size = value, | ||
| 3430 | A::Color(Some([r, g, b])) => style.color = u32::from_le_bytes([r, g, b, 0]), | ||
| 3431 | A::Bold(value) => style.bold = value, | ||
| 3432 | A::Italic(value) => style.italic = value, | ||
| 3433 | _ => unreachable!(), | ||
| 3434 | } | ||
| 3435 | } | ||
| 3436 | } | ||
| 3437 | let conflict = (start..end).any(|i| match attribute { | ||
| 3438 | A::FontSize(value) => expected[i].size != baseline[i].size && expected[i].size != value, | ||
| 3439 | A::Color(_) => expected[i].color != baseline[i].color && expected[i].color != 0x563412, | ||
| 3440 | A::Bold(value) => expected[i].bold != baseline[i].bold && expected[i].bold != value, | ||
| 3441 | _ => unreachable!(), | ||
| 3442 | }); | ||
| 3443 | drop(cache); | ||
| 3444 | let cache = Replica::open(&path).unwrap(); | ||
| 3445 | let mut server = Server::new(&remote); | ||
| 3446 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 3447 | assert_eq!(result.0, id, "seed {seed}"); | ||
| 3448 | if conflict { | ||
| 3449 | conflicts += 1; | ||
| 3450 | assert_eq!( | ||
| 3451 | result.1, | ||
| 3452 | EditStatus::Conflict(ConflictKind::FormattingChanged), | ||
| 3453 | "seed {seed}" | ||
| 3454 | ); | ||
| 3455 | assert_eq!(server.publications, 0, "seed {seed}"); | ||
| 3456 | assert_eq!(cache.snapshot().unwrap(), local, "seed {seed}"); | ||
| 3457 | continue; | ||
| 3458 | } | ||
| 3459 | published += 1; | ||
| 3460 | assert!( | ||
| 3461 | matches!(result.1, EditStatus::Published { .. }), | ||
| 3462 | "seed {seed}: {result:?}" | ||
| 3463 | ); | ||
| 3464 | for style in &mut expected[start..end] { | ||
| 3465 | match attribute { | ||
| 3466 | A::FontSize(value) => { | ||
| 3467 | satisfied_parts += usize::from(style.size == value); | ||
| 3468 | style.size = value; | ||
| 3469 | } | ||
| 3470 | A::Color(_) => { | ||
| 3471 | satisfied_parts += usize::from(style.color == 0x563412); | ||
| 3472 | style.color = 0x563412; | ||
| 3473 | } | ||
| 3474 | A::Bold(value) => { | ||
| 3475 | satisfied_parts += usize::from(style.bold == value); | ||
| 3476 | style.bold = value; | ||
| 3477 | } | ||
| 3478 | _ => unreachable!(), | ||
| 3479 | } | ||
| 3480 | } | ||
| 3481 | assert_eq!(text(&server.durable).2, original_text, "seed {seed}"); | ||
| 3482 | let store = Store::parse(&server.durable).unwrap(); | ||
| 3483 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3484 | let doc = Document::parse(&index).unwrap(); | ||
| 3485 | let space = &doc.spaces[&sid]; | ||
| 3486 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 3487 | let actual: Vec<_> = revision | ||
| 3488 | .text_runs(object) | ||
| 3489 | .unwrap() | ||
| 3490 | .iter() | ||
| 3491 | .flat_map(|run| { | ||
| 3492 | std::iter::repeat_n( | ||
| 3493 | Style { | ||
| 3494 | size: run.format.font_size.unwrap(), | ||
| 3495 | color: run.format.color.unwrap_or(0xff000000), | ||
| 3496 | bold: run.format.bold.unwrap_or(false), | ||
| 3497 | italic: run.format.italic.unwrap_or(false), | ||
| 3498 | }, | ||
| 3499 | run.text.chars().count(), | ||
| 3500 | ) | ||
| 3501 | }) | ||
| 3502 | .collect(); | ||
| 3503 | assert_eq!(actual, expected, "seed {seed}"); | ||
| 3504 | drop(cache); | ||
| 3505 | let cache = Replica::open(&path).unwrap(); | ||
| 3506 | assert_eq!(cache.status(id).unwrap(), Some(result.1)); | ||
| 3507 | let snapshot = cache.snapshot().unwrap(); | ||
| 3508 | assert_eq!(snapshot.len(), server.durable.len(), "seed {seed}"); | ||
| 3509 | assert!( | ||
| 3510 | snapshot | ||
| 3511 | .iter() | ||
| 3512 | .zip(&server.durable) | ||
| 3513 | .enumerate() | ||
| 3514 | .all(|(offset, (a, b))| a == b || (212..252).contains(&offset)), | ||
| 3515 | "seed {seed}: only confirmation version metadata may change" | ||
| 3516 | ); | ||
| 3517 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | ||
| 3518 | assert!( | ||
| 3519 | cache.snapshot().unwrap() == server.durable, | ||
| 3520 | "seed {seed}: refreshed snapshot" | ||
| 3521 | ); | ||
| 3522 | } | ||
| 3523 | assert!( | ||
| 3524 | conflicts >= 16 && published >= 32 && satisfied_parts >= 128, | ||
| 3525 | "conflicts={conflicts}, published={published}, already desired characters={satisfied_parts}" | ||
| 3526 | ); | ||
| 3527 | println!( | ||
| 3528 | "128 seeds: {conflicts} conflicts, {published} publications, {satisfied_parts} already desired characters" | ||
| 3529 | ); | ||
| 3530 | } | ||
| 3531 | |||
| 3532 | #[test] | ||
| 3533 | fn inserted_empty_text_retains_formatting_and_dependent_text_across_sync() { | ||
| 3534 | use onestore::{Insertion, TextAttribute as A}; | ||
| 3535 | let directory = tempfile::tempdir().unwrap(); | ||
| 3536 | let path = directory.path().join("empty.sqlite"); | ||
| 3537 | let source = onestore::create_section("empty.one", "Original", "Author").unwrap(); | ||
| 3538 | let store = Store::parse(&source).unwrap(); | ||
| 3539 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3540 | let doc = Document::parse(&index).unwrap(); | ||
| 3541 | let (sid, page) = doc.pages().unwrap()[0]; | ||
| 3542 | let insertion = Insertion::outline(page, 144.0, 144.0, "", "Author").unwrap(); | ||
| 3543 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3544 | let first = cache.insert(&source, sid, &insertion).unwrap().unwrap(); | ||
| 3545 | let second = cache | ||
| 3546 | .format( | ||
| 3547 | &cache.snapshot().unwrap(), | ||
| 3548 | sid, | ||
| 3549 | insertion.text_object(), | ||
| 3550 | 0..0, | ||
| 3551 | &[A::Bold(true), A::FontSize(18.0)], | ||
| 3552 | ) | ||
| 3553 | .unwrap() | ||
| 3554 | .unwrap(); | ||
| 3555 | let third = cache | ||
| 3556 | .edit_text( | ||
| 3557 | &cache.snapshot().unwrap(), | ||
| 3558 | sid, | ||
| 3559 | insertion.text_object(), | ||
| 3560 | 0..0, | ||
| 3561 | "Typed 🦀", | ||
| 3562 | ) | ||
| 3563 | .unwrap() | ||
| 3564 | .unwrap(); | ||
| 3565 | let pending = cache.pending().unwrap(); | ||
| 3566 | drop(cache); | ||
| 3567 | let cache = Replica::open(&path).unwrap(); | ||
| 3568 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 3569 | let mut server = Server::new(&source); | ||
| 3570 | for id in [first, second, third] { | ||
| 3571 | let result = cache.sync_once(&mut server).unwrap(); | ||
| 3572 | assert!( | ||
| 3573 | matches!(result,Some((actual,EditStatus::Published{..}))if actual==id), | ||
| 3574 | "{result:?}" | ||
| 3575 | ); | ||
| 3576 | } | ||
| 3577 | let store = Store::parse(&server.durable).unwrap(); | ||
| 3578 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3579 | let doc = Document::parse(&index).unwrap(); | ||
| 3580 | let space = &doc.spaces[&sid]; | ||
| 3581 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 3582 | let runs = revision.text_runs(insertion.text_object()).unwrap(); | ||
| 3583 | assert_eq!(runs.iter().map(|r| r.text).collect::<String>(), "Typed 🦀"); | ||
| 3584 | assert!( | ||
| 3585 | runs.iter() | ||
| 3586 | .all(|r| r.format.bold == Some(true) && r.format.font_size == Some(18.0)) | ||
| 3587 | ); | ||
| 3588 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 3589 | } | ||
| 3590 | |||
| 3591 | #[test] | ||
| 3592 | fn every_visual_attribute_rebases_with_an_independent_remote_attribute() { | ||
| 3593 | use onestore::TextAttribute as A; | ||
| 3594 | use serde_json::json; | ||
| 3595 | let mut cases = Vec::new(); | ||
| 3596 | for value in [false, true] { | ||
| 3597 | cases.extend([ | ||
| 3598 | (A::Bold(!value), A::Bold(value), "bold", json!(value)), | ||
| 3599 | (A::Italic(!value), A::Italic(value), "italic", json!(value)), | ||
| 3600 | ( | ||
| 3601 | A::Underline(!value), | ||
| 3602 | A::Underline(value), | ||
| 3603 | "underline", | ||
| 3604 | json!(value), | ||
| 3605 | ), | ||
| 3606 | (A::Strike(!value), A::Strike(value), "strike", json!(value)), | ||
| 3607 | ( | ||
| 3608 | A::Superscript(!value), | ||
| 3609 | A::Superscript(value), | ||
| 3610 | "superscript", | ||
| 3611 | json!(value), | ||
| 3612 | ), | ||
| 3613 | ( | ||
| 3614 | A::Subscript(!value), | ||
| 3615 | A::Subscript(value), | ||
| 3616 | "subscript", | ||
| 3617 | json!(value), | ||
| 3618 | ), | ||
| 3619 | ]); | ||
| 3620 | } | ||
| 3621 | cases.extend([ | ||
| 3622 | ( | ||
| 3623 | A::Font("Georgia".into()), | ||
| 3624 | A::Font("Arial".into()), | ||
| 3625 | "font", | ||
| 3626 | json!("Arial"), | ||
| 3627 | ), | ||
| 3628 | ( | ||
| 3629 | A::FontSize(11.0), | ||
| 3630 | A::FontSize(18.0), | ||
| 3631 | "font_size", | ||
| 3632 | json!(18.0), | ||
| 3633 | ), | ||
| 3634 | ( | ||
| 3635 | A::Color(None), | ||
| 3636 | A::Color(Some([1, 2, 3])), | ||
| 3637 | "color", | ||
| 3638 | json!(0x030201), | ||
| 3639 | ), | ||
| 3640 | ( | ||
| 3641 | A::Color(Some([1, 2, 3])), | ||
| 3642 | A::Color(None), | ||
| 3643 | "color", | ||
| 3644 | json!(0xff000000_u32), | ||
| 3645 | ), | ||
| 3646 | ( | ||
| 3647 | A::Highlight(None), | ||
| 3648 | A::Highlight(Some([4, 5, 6])), | ||
| 3649 | "highlight", | ||
| 3650 | json!(0x060504), | ||
| 3651 | ), | ||
| 3652 | ( | ||
| 3653 | A::Highlight(Some([4, 5, 6])), | ||
| 3654 | A::Highlight(None), | ||
| 3655 | "highlight", | ||
| 3656 | json!(0xff000000_u32), | ||
| 3657 | ), | ||
| 3658 | ]); | ||
| 3659 | for (baseline, desired, field, value) in cases { | ||
| 3660 | let directory = tempfile::tempdir().unwrap(); | ||
| 3661 | let path = directory.path().join("attribute.sqlite"); | ||
| 3662 | let source = onestore::create_section("attribute.one", "abc", "Author").unwrap(); | ||
| 3663 | let (sid, object, _) = text(&source); | ||
| 3664 | let source = PreparedEdit::format(&source, sid, object, 0..3, &[baseline]) | ||
| 3665 | .unwrap() | ||
| 3666 | .as_bytes() | ||
| 3667 | .to_vec(); | ||
| 3668 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3669 | let id = cache | ||
| 3670 | .format(&source, sid, object, 0..3, &[desired]) | ||
| 3671 | .unwrap() | ||
| 3672 | .unwrap(); | ||
| 3673 | let moved = PreparedEdit::text(&source, sid, object, 0..0, "Z").unwrap(); | ||
| 3674 | let (other, other_field, other_value) = if field == "font_size" { | ||
| 3675 | (A::Italic(true), "italic", json!(true)) | ||
| 3676 | } else { | ||
| 3677 | (A::FontSize(22.0), "font_size", json!(22.0)) | ||
| 3678 | }; | ||
| 3679 | let remote = PreparedEdit::format(moved.as_bytes(), sid, object, 1..4, &[other]).unwrap(); | ||
| 3680 | let mut server = Server::new(remote.as_bytes()); | ||
| 3681 | drop(cache); | ||
| 3682 | let cache = Replica::open(&path).unwrap(); | ||
| 3683 | let result = cache.sync_once(&mut server).unwrap(); | ||
| 3684 | assert!( | ||
| 3685 | matches!(result,Some((observed,EditStatus::Published{..}))if observed==id), | ||
| 3686 | "{field}={value}: {result:?}" | ||
| 3687 | ); | ||
| 3688 | assert_eq!(text(&server.durable).2, "Zabc"); | ||
| 3689 | let store = Store::parse(&server.durable).unwrap(); | ||
| 3690 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3691 | let doc = Document::parse(&index).unwrap(); | ||
| 3692 | let space = &doc.spaces[&sid]; | ||
| 3693 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 3694 | let mut position = 0; | ||
| 3695 | for run in revision.text_runs(object).unwrap() { | ||
| 3696 | let format = serde_json::to_value(run.format).unwrap(); | ||
| 3697 | for _ in run.text.chars() { | ||
| 3698 | if position > 0 { | ||
| 3699 | assert_eq!(format[field], value, "{field}, character {position}"); | ||
| 3700 | assert_eq!( | ||
| 3701 | format[other_field], other_value, | ||
| 3702 | "{field}, character {position}" | ||
| 3703 | ); | ||
| 3704 | } | ||
| 3705 | position += 1; | ||
| 3706 | } | ||
| 3707 | } | ||
| 3708 | assert_eq!(position, 4); | ||
| 3709 | } | ||
| 3710 | } | ||
| 3711 | |||
| 3712 | #[test] | ||
| 3713 | fn reviewed_insertion_placements_preserve_identity_and_dependent_operations() { | ||
| 3714 | use notebook::Operation; | ||
| 3715 | use onestore::{Insertion, TextAttribute as A}; | ||
| 3716 | for outline_case in [false, true] { | ||
| 3717 | let directory = tempfile::tempdir().unwrap(); | ||
| 3718 | let path = directory.path().join("placement.sqlite"); | ||
| 3719 | let base = onestore::create_section("placement.one", "Original", "Author").unwrap(); | ||
| 3720 | let (sid, _, _) = text(&base); | ||
| 3721 | let store = Store::parse(&base).unwrap(); | ||
| 3722 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3723 | let doc = Document::parse(&index).unwrap(); | ||
| 3724 | let (_, page) = doc.pages().unwrap()[0]; | ||
| 3725 | let space = &doc.spaces[&sid]; | ||
| 3726 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 3727 | let parent = *view.nodes[&page] | ||
| 3728 | .children | ||
| 3729 | .iter() | ||
| 3730 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 3731 | .unwrap(); | ||
| 3732 | let anchor = Insertion::paragraph(parent, None, "Temporary anchor", "Author").unwrap(); | ||
| 3733 | let source = PreparedEdit::insert(&base, sid, &anchor) | ||
| 3734 | .unwrap() | ||
| 3735 | .as_bytes() | ||
| 3736 | .to_vec(); | ||
| 3737 | let insertion = if outline_case { | ||
| 3738 | Insertion::outline(page, 144.0, 144.0, "Offline", "Author").unwrap() | ||
| 3739 | } else { | ||
| 3740 | Insertion::paragraph(parent, Some(anchor.object()), "Offline", "Author").unwrap() | ||
| 3741 | } | ||
| 3742 | .with_formatting(0..7, &[A::Italic(true)]) | ||
| 3743 | .unwrap(); | ||
| 3744 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3745 | let first = cache.insert(&source, sid, &insertion).unwrap().unwrap(); | ||
| 3746 | let second = cache | ||
| 3747 | .format( | ||
| 3748 | &cache.snapshot().unwrap(), | ||
| 3749 | sid, | ||
| 3750 | insertion.text_object(), | ||
| 3751 | 0..7, | ||
| 3752 | &[A::Bold(true)], | ||
| 3753 | ) | ||
| 3754 | .unwrap() | ||
| 3755 | .unwrap(); | ||
| 3756 | let third = cache | ||
| 3757 | .edit_text( | ||
| 3758 | &cache.snapshot().unwrap(), | ||
| 3759 | sid, | ||
| 3760 | insertion.text_object(), | ||
| 3761 | 7..7, | ||
| 3762 | " 🦀", | ||
| 3763 | ) | ||
| 3764 | .unwrap() | ||
| 3765 | .unwrap(); | ||
| 3766 | let local = cache.snapshot().unwrap(); | ||
| 3767 | let pending = cache.pending().unwrap(); | ||
| 3768 | let mut server = Server::new(&base); | ||
| 3769 | if outline_case { | ||
| 3770 | drop(cache); | ||
| 3771 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 3772 | db.execute( | ||
| 3773 | "INSERT INTO conflicts VALUES (?1,2)", | ||
| 3774 | [i64::try_from(first).unwrap()], | ||
| 3775 | ) | ||
| 3776 | .unwrap(); | ||
| 3777 | db.execute("UPDATE replica SET base=?1", [&base]).unwrap(); | ||
| 3778 | drop(db); | ||
| 3779 | } else { | ||
| 3780 | assert_eq!( | ||
| 3781 | cache.sync_once(&mut server).unwrap(), | ||
| 3782 | Some((first, EditStatus::Conflict(ConflictKind::UnsupportedEdit))) | ||
| 3783 | ); | ||
| 3784 | drop(cache); | ||
| 3785 | } | ||
| 3786 | let cache = Replica::open(&path).unwrap(); | ||
| 3787 | if outline_case { | ||
| 3788 | assert!( | ||
| 3789 | cache | ||
| 3790 | .rebase_paragraph_conflict(first, &local, &base, parent, None) | ||
| 3791 | .is_err() | ||
| 3792 | ); | ||
| 3793 | assert!( | ||
| 3794 | cache | ||
| 3795 | .rebase_outline_conflict(first, &local, &base, page, f32::NAN, 288.0) | ||
| 3796 | .is_err() | ||
| 3797 | ); | ||
| 3798 | assert!( | ||
| 3799 | matches!(cache.rebase_outline_conflict(first,&source,&base,page,288.0,360.0),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy) | ||
| 3800 | ); | ||
| 3801 | cache | ||
| 3802 | .rebase_outline_conflict(first, &local, &base, page, 288.0, 360.0) | ||
| 3803 | .unwrap(); | ||
| 3804 | } else { | ||
| 3805 | assert!( | ||
| 3806 | cache | ||
| 3807 | .rebase_outline_conflict(first, &local, &base, page, 288.0, 360.0) | ||
| 3808 | .is_err() | ||
| 3809 | ); | ||
| 3810 | assert!( | ||
| 3811 | cache | ||
| 3812 | .rebase_paragraph_conflict(first, &local, &base, parent, Some(anchor.object())) | ||
| 3813 | .is_err() | ||
| 3814 | ); | ||
| 3815 | assert!( | ||
| 3816 | matches!(cache.rebase_paragraph_conflict(first,&source,&base,parent,None),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy) | ||
| 3817 | ); | ||
| 3818 | cache | ||
| 3819 | .rebase_paragraph_conflict(first, &local, &base, parent, None) | ||
| 3820 | .unwrap(); | ||
| 3821 | } | ||
| 3822 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3823 | assert_eq!(cache.pending().unwrap()[1..], pending[1..]); | ||
| 3824 | let Operation::Insert(rebased) = cache.pending().unwrap().remove(0).operation else { | ||
| 3825 | panic!() | ||
| 3826 | }; | ||
| 3827 | assert_eq!(rebased.object(), insertion.object()); | ||
| 3828 | assert_eq!(rebased.text_object(), insertion.text_object()); | ||
| 3829 | assert_eq!( | ||
| 3830 | serde_json::to_value(&rebased).unwrap()["formats"], | ||
| 3831 | serde_json::to_value(&insertion).unwrap()["formats"] | ||
| 3832 | ); | ||
| 3833 | assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending)); | ||
| 3834 | drop(cache); | ||
| 3835 | let cache = Replica::open(&path).unwrap(); | ||
| 3836 | for id in [first, second, third] { | ||
| 3837 | let result = cache.sync_once(&mut server).unwrap(); | ||
| 3838 | assert!( | ||
| 3839 | matches!(result,Some((actual,EditStatus::Published{..}))if actual==id), | ||
| 3840 | "{result:?}" | ||
| 3841 | ); | ||
| 3842 | } | ||
| 3843 | let store = Store::parse(&server.durable).unwrap(); | ||
| 3844 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3845 | let doc = Document::parse(&index).unwrap(); | ||
| 3846 | let space = &doc.spaces[&sid]; | ||
| 3847 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 3848 | let runs = view.text_runs(insertion.text_object()).unwrap(); | ||
| 3849 | assert_eq!( | ||
| 3850 | runs.iter().map(|r| r.text).collect::<String>(), | ||
| 3851 | "Offline 🦀" | ||
| 3852 | ); | ||
| 3853 | assert!(runs.iter().all(|r| r.format.bold == Some(true))); | ||
| 3854 | assert!(runs.iter().all(|r| r.format.italic == Some(true))); | ||
| 3855 | if outline_case { | ||
| 3856 | assert_eq!( | ||
| 3857 | ( | ||
| 3858 | view.nodes[&insertion.object()].layout.x, | ||
| 3859 | view.nodes[&insertion.object()].layout.y | ||
| 3860 | ), | ||
| 3861 | (Some(288.0), Some(360.0)) | ||
| 3862 | ); | ||
| 3863 | } else { | ||
| 3864 | assert_eq!( | ||
| 3865 | view.nodes[&parent].children.last(), | ||
| 3866 | Some(&insertion.object()) | ||
| 3867 | ); | ||
| 3868 | } | ||
| 3869 | assert!(!view.nodes.contains_key(&anchor.object())); | ||
| 3870 | assert_eq!(server.publications, 3); | ||
| 3871 | assert!(cache.pending().unwrap().is_empty()); | ||
| 3872 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 3873 | } | ||
| 3874 | } | ||
| 3875 | |||
| 3876 | #[test] | ||
| 3877 | fn placement_reviews_cannot_replace_pending_or_uncertain_attempts() { | ||
| 3878 | use onestore::Insertion; | ||
| 3879 | for outline_case in [false, true] { | ||
| 3880 | let directory = tempfile::tempdir().unwrap(); | ||
| 3881 | let path = directory.path().join("attempt-placement.sqlite"); | ||
| 3882 | let source = onestore::create_section("placement.one", "Original", "Author").unwrap(); | ||
| 3883 | let store = Store::parse(&source).unwrap(); | ||
| 3884 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3885 | let doc = Document::parse(&index).unwrap(); | ||
| 3886 | let (sid, page) = doc.pages().unwrap()[0]; | ||
| 3887 | let space = &doc.spaces[&sid]; | ||
| 3888 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 3889 | let parent = *view.nodes[&page] | ||
| 3890 | .children | ||
| 3891 | .iter() | ||
| 3892 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 3893 | .unwrap(); | ||
| 3894 | let insertion = if outline_case { | ||
| 3895 | Insertion::outline(page, 144.0, 144.0, "Offline", "Author").unwrap() | ||
| 3896 | } else { | ||
| 3897 | Insertion::paragraph(parent, None, "Offline", "Author").unwrap() | ||
| 3898 | }; | ||
| 3899 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3900 | let id = cache.insert(&source, sid, &insertion).unwrap().unwrap(); | ||
| 3901 | let local = cache.snapshot().unwrap(); | ||
| 3902 | let pending = cache.pending().unwrap(); | ||
| 3903 | let mut server = Server::new(&source); | ||
| 3904 | for attempted in [false, true] { | ||
| 3905 | if attempted { | ||
| 3906 | server.fault = Fault::UnknownBefore; | ||
| 3907 | assert!( | ||
| 3908 | matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown) | ||
| 3909 | ); | ||
| 3910 | } | ||
| 3911 | let state = cache.status(id).unwrap(); | ||
| 3912 | let result = if outline_case { | ||
| 3913 | cache.rebase_outline_conflict(id, &local, &source, page, 288.0, 360.0) | ||
| 3914 | } else { | ||
| 3915 | cache.rebase_paragraph_conflict(id, &local, &source, parent, None) | ||
| 3916 | }; | ||
| 3917 | assert!(matches!(result,Err(Error::Io(e))if e.kind()==io::ErrorKind::InvalidInput)); | ||
| 3918 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 3919 | assert_eq!(cache.status(id).unwrap(), state); | ||
| 3920 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3921 | } | ||
| 3922 | drop(cache); | ||
| 3923 | let cache = Replica::open(&path).unwrap(); | ||
| 3924 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 3925 | assert!(matches!( | ||
| 3926 | cache.sync_once(&mut server).unwrap(), | ||
| 3927 | Some((_, EditStatus::AwaitingConfirmation { .. })) | ||
| 3928 | )); | ||
| 3929 | assert_eq!(server.publications, 1); | ||
| 3930 | } | ||
| 3931 | } | ||
crates/notebook/tests/sync/outline.rs created+719| ... | @@ -0,0 +1,719 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::OutlineEdit as Change; | ||
| 3 | |||
| 4 | pub(super) fn fixture() -> (Vec<u8>, ExGuid, ExGuid, ExGuid, ExGuid) { | ||
| 5 | let source = onestore::create_section("layout.one", "Original 🦀 é", "Author").unwrap(); | ||
| 6 | let (sid, text, _) = text(&source); | ||
| 7 | let store = Store::parse(&source).unwrap(); | ||
| 8 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 9 | let document = Document::parse(&index).unwrap(); | ||
| 10 | let space = &document.spaces[&sid]; | ||
| 11 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 12 | let paragraph = *view | ||
| 13 | .nodes | ||
| 14 | .iter() | ||
| 15 | .find(|(_, node)| node.content == [text]) | ||
| 16 | .unwrap() | ||
| 17 | .0; | ||
| 18 | let outline = *view | ||
| 19 | .nodes | ||
| 20 | .iter() | ||
| 21 | .find(|(_, node)| node.children.contains(&paragraph)) | ||
| 22 | .unwrap() | ||
| 23 | .0; | ||
| 24 | (source, sid, outline, paragraph, text) | ||
| 25 | } | ||
| 26 | |||
| 27 | pub(super) fn node(source: &[u8], sid: ExGuid, object: ExGuid) -> serde_json::Value { | ||
| 28 | let store = Store::parse(source).unwrap(); | ||
| 29 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 30 | index.validate_current().unwrap(); | ||
| 31 | let document = Document::parse(&index).unwrap(); | ||
| 32 | let space = &document.spaces[&sid]; | ||
| 33 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 34 | serde_json::to_value(&view.nodes[&object]).unwrap() | ||
| 35 | } | ||
| 36 | |||
| 37 | #[test] | ||
| 38 | fn layout_and_dependent_text_survive_reopen_and_independent_remote_formatting() { | ||
| 39 | let (source, sid, outline, paragraph, text_id) = fixture(); | ||
| 40 | for (object, change) in [ | ||
| 41 | (outline, Change::Position { x: 180.0, y: 216.0 }), | ||
| 42 | ( | ||
| 43 | outline, | ||
| 44 | Change::Width { | ||
| 45 | points: 144.0, | ||
| 46 | user_set: true, | ||
| 47 | }, | ||
| 48 | ), | ||
| 49 | (paragraph, Change::Collapsed(true)), | ||
| 50 | ] { | ||
| 51 | let directory = tempfile::tempdir().unwrap(); | ||
| 52 | let path = directory.path().join("cache.sqlite"); | ||
| 53 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 54 | let id = cache | ||
| 55 | .outline(&source, sid, object, change) | ||
| 56 | .unwrap() | ||
| 57 | .unwrap(); | ||
| 58 | let local = cache.snapshot().unwrap(); | ||
| 59 | let expected = node(&local, sid, object); | ||
| 60 | let dependent = cache | ||
| 61 | .edit_text(&local, sid, text_id, 0..0, "Local ") | ||
| 62 | .unwrap() | ||
| 63 | .unwrap(); | ||
| 64 | let pending = cache.pending().unwrap(); | ||
| 65 | let local = cache.snapshot().unwrap(); | ||
| 66 | drop(cache); | ||
| 67 | let cache = Replica::open(&path).unwrap(); | ||
| 68 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 69 | assert!(cache.snapshot().unwrap() == local); | ||
| 70 | let remote = PreparedEdit::format( | ||
| 71 | &source, | ||
| 72 | sid, | ||
| 73 | text_id, | ||
| 74 | 0..8, | ||
| 75 | &[onestore::TextAttribute::Bold(true)], | ||
| 76 | ) | ||
| 77 | .unwrap(); | ||
| 78 | let mut server = Server::new(remote.as_bytes()); | ||
| 79 | assert!( | ||
| 80 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) | ||
| 81 | ); | ||
| 82 | let actual = node(&server.durable, sid, object); | ||
| 83 | assert_eq!(actual["layout"], expected["layout"]); | ||
| 84 | assert_eq!(actual["kind"], expected["kind"]); | ||
| 85 | assert_eq!(cache.pending().unwrap().len(), 1); | ||
| 86 | drop(cache); | ||
| 87 | let cache = Replica::open(&path).unwrap(); | ||
| 88 | assert!( | ||
| 89 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==dependent) | ||
| 90 | ); | ||
| 91 | assert_eq!(text(&server.durable).2, "Local Original 🦀 é"); | ||
| 92 | assert!(cache.pending().unwrap().is_empty()); | ||
| 93 | let store = Store::parse(&server.durable).unwrap(); | ||
| 94 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 95 | let document = Document::parse(&index).unwrap(); | ||
| 96 | let space = &document.spaces[&sid]; | ||
| 97 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 98 | assert!(view.text_runs(text_id).unwrap()[0].format.bold.unwrap()); | ||
| 99 | assert_eq!(server.publications, 2); | ||
| 100 | drop(cache); | ||
| 101 | let cache = Replica::open(&path).unwrap(); | ||
| 102 | for id in [id, dependent] { | ||
| 103 | assert!(matches!( | ||
| 104 | cache.status(id).unwrap(), | ||
| 105 | Some(EditStatus::Published { .. }) | ||
| 106 | )); | ||
| 107 | } | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | #[test] | ||
| 112 | fn rebased_outline_movement_uses_remote_title_text_and_confirms_after_reopen() { | ||
| 113 | let (source, sid, outline, _, text_id) = fixture(); | ||
| 114 | let store = Store::parse(&source).unwrap(); | ||
| 115 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 116 | let document = Document::parse(&index).unwrap(); | ||
| 117 | let (_, page) = document.pages().unwrap()[0]; | ||
| 118 | let space = &document.spaces[&sid]; | ||
| 119 | let metadata = space.revisions[&space.contexts[&ExGuid::default()]].roots[&2]; | ||
| 120 | let second = onestore::Insertion::outline(page, 144.0, 36.0, "Second", "Author").unwrap(); | ||
| 121 | let source = PreparedEdit::insert(&source, sid, &second) | ||
| 122 | .unwrap() | ||
| 123 | .as_bytes() | ||
| 124 | .to_vec(); | ||
| 125 | let directory = tempfile::tempdir().unwrap(); | ||
| 126 | let path = directory.path().join("titles.sqlite"); | ||
| 127 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 128 | let movement = cache | ||
| 129 | .outline( | ||
| 130 | &source, | ||
| 131 | sid, | ||
| 132 | outline, | ||
| 133 | Change::Position { x: 216.0, y: 72.0 }, | ||
| 134 | ) | ||
| 135 | .unwrap() | ||
| 136 | .unwrap(); | ||
| 137 | let local = cache.snapshot().unwrap(); | ||
| 138 | assert_eq!(node(&local, sid, metadata)["kind"]["title"], "Second"); | ||
| 139 | let dependent = cache | ||
| 140 | .edit_text(&local, sid, text_id, 0..0, "Local ") | ||
| 141 | .unwrap() | ||
| 142 | .unwrap(); | ||
| 143 | drop(cache); | ||
| 144 | let remote = | ||
| 145 | PreparedEdit::text(&source, sid, second.text_object(), 0..6, "Remote second 🐈").unwrap(); | ||
| 146 | let mut server = Server::new(remote.as_bytes()); | ||
| 147 | server.fault = Fault::UnknownAfter; | ||
| 148 | let cache = Replica::open(&path).unwrap(); | ||
| 149 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 150 | assert!(matches!( | ||
| 151 | cache.status(movement).unwrap(), | ||
| 152 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 153 | )); | ||
| 154 | assert_eq!( | ||
| 155 | node(&server.durable, sid, metadata)["kind"]["title"], | ||
| 156 | "Original 🦀 é" | ||
| 157 | ); | ||
| 158 | assert_eq!( | ||
| 159 | node(&server.visible, sid, metadata)["kind"]["title"], | ||
| 160 | "Remote second 🐈" | ||
| 161 | ); | ||
| 162 | drop(cache); | ||
| 163 | for id in [movement, dependent] { | ||
| 164 | let cache = Replica::open(&path).unwrap(); | ||
| 165 | assert!( | ||
| 166 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | ||
| 167 | ); | ||
| 168 | assert_eq!( | ||
| 169 | node(&server.durable, sid, metadata)["kind"]["title"], | ||
| 170 | "Remote second 🐈" | ||
| 171 | ); | ||
| 172 | assert_eq!( | ||
| 173 | node(&server.durable, sid, page)["kind"]["alternate_title"], | ||
| 174 | "Remote second 🐈" | ||
| 175 | ); | ||
| 176 | } | ||
| 177 | let cache = Replica::open(&path).unwrap(); | ||
| 178 | assert!(cache.pending().unwrap().is_empty()); | ||
| 179 | assert_eq!( | ||
| 180 | node(&cache.snapshot().unwrap(), sid, text_id)["kind"]["text"], | ||
| 181 | "Local Original 🦀 é" | ||
| 182 | ); | ||
| 183 | assert_eq!(server.publications, 2); | ||
| 184 | } | ||
| 185 | |||
| 186 | #[test] | ||
| 187 | fn competing_layout_requires_current_review_and_preserves_dependent_edits() { | ||
| 188 | let (source, sid, outline, _, text_id) = fixture(); | ||
| 189 | for (local_change, remote_change) in [ | ||
| 190 | ( | ||
| 191 | Change::Position { x: 180.0, y: 216.0 }, | ||
| 192 | Change::Position { x: 288.0, y: 216.0 }, | ||
| 193 | ), | ||
| 194 | ( | ||
| 195 | Change::Width { | ||
| 196 | points: 144.0, | ||
| 197 | user_set: true, | ||
| 198 | }, | ||
| 199 | Change::Width { | ||
| 200 | points: 216.0, | ||
| 201 | user_set: false, | ||
| 202 | }, | ||
| 203 | ), | ||
| 204 | ] { | ||
| 205 | let directory = tempfile::tempdir().unwrap(); | ||
| 206 | let path = directory.path().join("cache.sqlite"); | ||
| 207 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 208 | let id = cache | ||
| 209 | .outline(&source, sid, outline, local_change) | ||
| 210 | .unwrap() | ||
| 211 | .unwrap(); | ||
| 212 | let dependent = cache | ||
| 213 | .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ") | ||
| 214 | .unwrap() | ||
| 215 | .unwrap(); | ||
| 216 | let local = cache.snapshot().unwrap(); | ||
| 217 | let queue = cache.pending().unwrap(); | ||
| 218 | let remote = PreparedEdit::outline(&source, sid, outline, remote_change).unwrap(); | ||
| 219 | let mut server = Server::new(remote.as_bytes()); | ||
| 220 | assert_eq!( | ||
| 221 | cache.sync_once(&mut server).unwrap(), | ||
| 222 | Some((id, EditStatus::Conflict(ConflictKind::LayoutChanged))) | ||
| 223 | ); | ||
| 224 | assert_eq!(server.publications, 0); | ||
| 225 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 226 | assert!(cache.snapshot().unwrap() == local); | ||
| 227 | assert!( | ||
| 228 | cache | ||
| 229 | .rebase_layout_conflict(id, &source, &server.visible) | ||
| 230 | .is_err() | ||
| 231 | ); | ||
| 232 | assert!(cache.rebase_layout_conflict(id, &local, &source).is_err()); | ||
| 233 | assert!( | ||
| 234 | cache | ||
| 235 | .rebase_conflict(id, &local, &server.visible, 0..0) | ||
| 236 | .is_err() | ||
| 237 | ); | ||
| 238 | drop(cache); | ||
| 239 | let cache = Replica::open(&path).unwrap(); | ||
| 240 | assert_eq!( | ||
| 241 | cache.status(id).unwrap(), | ||
| 242 | Some(EditStatus::Conflict(ConflictKind::LayoutChanged)) | ||
| 243 | ); | ||
| 244 | cache | ||
| 245 | .rebase_layout_conflict(id, &local, &server.visible) | ||
| 246 | .unwrap(); | ||
| 247 | let reviewed = cache.pending().unwrap(); | ||
| 248 | assert_eq!(reviewed[1], queue[1]); | ||
| 249 | assert!(cache.snapshot().unwrap() == local); | ||
| 250 | drop(cache); | ||
| 251 | let cache = Replica::open(&path).unwrap(); | ||
| 252 | assert_eq!(cache.pending().unwrap(), reviewed); | ||
| 253 | for id in [id, dependent] { | ||
| 254 | assert!( | ||
| 255 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | ||
| 256 | ); | ||
| 257 | } | ||
| 258 | assert_eq!( | ||
| 259 | node(&server.durable, sid, outline)["layout"], | ||
| 260 | node(&local, sid, outline)["layout"] | ||
| 261 | ); | ||
| 262 | assert_eq!(text(&server.durable).2, "Local Original 🦀 é"); | ||
| 263 | } | ||
| 264 | } | ||
| 265 | |||
| 266 | #[test] | ||
| 267 | fn independent_axes_converge_without_overwriting_competing_values() { | ||
| 268 | let (source, sid, outline, _, _) = fixture(); | ||
| 269 | let directory = tempfile::tempdir().unwrap(); | ||
| 270 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); | ||
| 271 | let original = node(&source, sid, outline); | ||
| 272 | let local = Change::Position { x: 180.0, y: 216.0 }; | ||
| 273 | let id = cache | ||
| 274 | .outline(&source, sid, outline, local) | ||
| 275 | .unwrap() | ||
| 276 | .unwrap(); | ||
| 277 | let remote = PreparedEdit::outline( | ||
| 278 | &source, | ||
| 279 | sid, | ||
| 280 | outline, | ||
| 281 | Change::Position { | ||
| 282 | x: original["layout"]["x"].as_f64().unwrap() as f32, | ||
| 283 | y: 216.0, | ||
| 284 | }, | ||
| 285 | ) | ||
| 286 | .unwrap(); | ||
| 287 | let mut server = Server::new(remote.as_bytes()); | ||
| 288 | assert!( | ||
| 289 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) | ||
| 290 | ); | ||
| 291 | let actual = node(&server.durable, sid, outline); | ||
| 292 | assert_eq!(actual["layout"]["x"], 180.0); | ||
| 293 | assert_eq!(actual["layout"]["y"], 216.0); | ||
| 294 | } | ||
| 295 | |||
| 296 | #[test] | ||
| 297 | fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restarts() { | ||
| 298 | let (source, sid, outline, paragraph, _) = fixture(); | ||
| 299 | let directory = tempfile::tempdir().unwrap(); | ||
| 300 | std::thread::scope(|scope| { | ||
| 301 | let mut writers = Vec::new(); | ||
| 302 | for actor in 0..12 { | ||
| 303 | let source = &source; | ||
| 304 | let path = directory.path().join(format!("{actor}.sqlite")); | ||
| 305 | writers.push(scope.spawn(move || { | ||
| 306 | let cache = Replica::create(&path, source).unwrap(); | ||
| 307 | for round in 0..4 { | ||
| 308 | for (object, change) in [ | ||
| 309 | ( | ||
| 310 | outline, | ||
| 311 | Change::Position { | ||
| 312 | x: (actor + 2) as f32 * 36.0, | ||
| 313 | y: (round + 2) as f32 * 36.0, | ||
| 314 | }, | ||
| 315 | ), | ||
| 316 | ( | ||
| 317 | outline, | ||
| 318 | Change::Width { | ||
| 319 | points: (actor + round + 2) as f32 * 36.0, | ||
| 320 | user_set: true, | ||
| 321 | }, | ||
| 322 | ), | ||
| 323 | (paragraph, Change::Collapsed((actor + round) % 2 != 0)), | ||
| 324 | ] { | ||
| 325 | assert!( | ||
| 326 | cache | ||
| 327 | .outline(&cache.snapshot().unwrap(), sid, object, change) | ||
| 328 | .unwrap() | ||
| 329 | .is_some() | ||
| 330 | ); | ||
| 331 | } | ||
| 332 | } | ||
| 333 | assert_eq!(cache.pending().unwrap().len(), 12); | ||
| 334 | })); | ||
| 335 | } | ||
| 336 | for writer in writers { | ||
| 337 | writer.join().unwrap(); | ||
| 338 | } | ||
| 339 | }); | ||
| 340 | let mut server = Server::new(&source); | ||
| 341 | let mut expected_layout = node(&source, sid, outline)["layout"].clone(); | ||
| 342 | let mut expected_collapse = None; | ||
| 343 | let mut reviewed = 0; | ||
| 344 | for round in 0..12 { | ||
| 345 | for actor in 0..12 { | ||
| 346 | let path = directory.path().join(format!("{actor}.sqlite")); | ||
| 347 | let cache = Replica::open(&path).unwrap(); | ||
| 348 | let first = cache.pending().unwrap()[0].clone(); | ||
| 349 | let notebook::Operation::Outline(edit) = first.operation else { | ||
| 350 | panic!() | ||
| 351 | }; | ||
| 352 | let local = cache.snapshot().unwrap(); | ||
| 353 | let result = cache.sync_once(&mut server).unwrap(); | ||
| 354 | if result == Some((first.id, EditStatus::Conflict(ConflictKind::LayoutChanged))) { | ||
| 355 | let remote = cache.remote_snapshot().unwrap(); | ||
| 356 | cache | ||
| 357 | .rebase_layout_conflict(first.id, &local, &remote) | ||
| 358 | .unwrap(); | ||
| 359 | drop(cache); | ||
| 360 | let cache = Replica::open(&path).unwrap(); | ||
| 361 | assert!( | ||
| 362 | matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == first.id) | ||
| 363 | ); | ||
| 364 | reviewed += 1; | ||
| 365 | } else { | ||
| 366 | assert!( | ||
| 367 | matches!(result, Some((id, EditStatus::Published { .. })) if id == first.id) | ||
| 368 | ); | ||
| 369 | } | ||
| 370 | match edit.change { | ||
| 371 | Change::Position { x, y } => { | ||
| 372 | expected_layout["x"] = x.into(); | ||
| 373 | expected_layout["y"] = y.into(); | ||
| 374 | } | ||
| 375 | Change::Width { points, user_set } => { | ||
| 376 | expected_layout["max_width"] = points.into(); | ||
| 377 | expected_layout["width_set_by_user"] = user_set.into(); | ||
| 378 | } | ||
| 379 | Change::Collapsed(value) => expected_collapse = Some(u8::from(value)), | ||
| 380 | } | ||
| 381 | assert_eq!( | ||
| 382 | node(&server.durable, sid, outline)["layout"], | ||
| 383 | expected_layout | ||
| 384 | ); | ||
| 385 | assert_eq!( | ||
| 386 | node(&server.durable, sid, paragraph)["kind"]["collapse_state"], | ||
| 387 | serde_json::json!(expected_collapse) | ||
| 388 | ); | ||
| 389 | assert_eq!(text(&server.durable).2, "Original 🦀 é"); | ||
| 390 | } | ||
| 391 | for actor in 0..12 { | ||
| 392 | let cache = Replica::open(directory.path().join(format!("{actor}.sqlite"))).unwrap(); | ||
| 393 | assert_eq!(cache.pending().unwrap().len(), 11 - round); | ||
| 394 | } | ||
| 395 | } | ||
| 396 | assert!(reviewed >= 12); | ||
| 397 | for actor in 0..12 { | ||
| 398 | let cache = Replica::open(directory.path().join(format!("{actor}.sqlite"))).unwrap(); | ||
| 399 | assert!(cache.pending().unwrap().is_empty()); | ||
| 400 | for id in 1..=12 { | ||
| 401 | assert!(matches!( | ||
| 402 | cache.status(id).unwrap(), | ||
| 403 | Some(EditStatus::Published { .. }) | ||
| 404 | )); | ||
| 405 | } | ||
| 406 | } | ||
| 407 | } | ||
| 408 | |||
| 409 | #[test] | ||
| 410 | fn uncertain_layout_is_confirmed_by_revision_and_never_by_converged_values() { | ||
| 411 | let (source, sid, outline, _, text_id) = fixture(); | ||
| 412 | for fault in [Fault::UnknownBefore, Fault::UnknownAfter] { | ||
| 413 | let before = matches!(fault, Fault::UnknownBefore); | ||
| 414 | let directory = tempfile::tempdir().unwrap(); | ||
| 415 | let path = directory.path().join("cache.sqlite"); | ||
| 416 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 417 | let change = Change::Width { | ||
| 418 | points: 144.0, | ||
| 419 | user_set: true, | ||
| 420 | }; | ||
| 421 | let id = cache | ||
| 422 | .outline(&source, sid, outline, change) | ||
| 423 | .unwrap() | ||
| 424 | .unwrap(); | ||
| 425 | cache | ||
| 426 | .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ") | ||
| 427 | .unwrap(); | ||
| 428 | let local = cache.snapshot().unwrap(); | ||
| 429 | let mut server = Server::new(&source); | ||
| 430 | server.fault = fault; | ||
| 431 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 432 | let status = cache.status(id).unwrap().unwrap(); | ||
| 433 | assert!(matches!(status, EditStatus::AwaitingConfirmation { .. })); | ||
| 434 | drop(cache); | ||
| 435 | let cache = Replica::open(&path).unwrap(); | ||
| 436 | if before { | ||
| 437 | let converged = PreparedEdit::outline(&source, sid, outline, change).unwrap(); | ||
| 438 | server.visible = converged.as_bytes().to_vec(); | ||
| 439 | assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, status))); | ||
| 440 | assert!( | ||
| 441 | cache | ||
| 442 | .rebase_layout_conflict(id, &local, &server.visible) | ||
| 443 | .is_err() | ||
| 444 | ); | ||
| 445 | assert_eq!(cache.pending().unwrap().len(), 2); | ||
| 446 | assert!(cache.snapshot().unwrap() == local); | ||
| 447 | let archive = directory.path().join("recovery.sqlite"); | ||
| 448 | cache.export_recovery(&archive).unwrap(); | ||
| 449 | let recovery = notebook::Recovery::open(&archive).unwrap(); | ||
| 450 | assert_eq!(recovery.pending().unwrap(), cache.pending().unwrap()); | ||
| 451 | assert_eq!(recovery.status(id).unwrap(), Some(status)); | ||
| 452 | assert_eq!(server.confirmations, 0); | ||
| 453 | } else { | ||
| 454 | assert!( | ||
| 455 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) | ||
| 456 | ); | ||
| 457 | assert_eq!( | ||
| 458 | node(&server.durable, sid, outline)["layout"]["max_width"], | ||
| 459 | 144.0 | ||
| 460 | ); | ||
| 461 | assert_eq!(server.confirmations, 1); | ||
| 462 | } | ||
| 463 | assert_eq!(server.publications, 1); | ||
| 464 | } | ||
| 465 | } | ||
| 466 | |||
| 467 | #[test] | ||
| 468 | fn native_moves_deletions_and_layout_changes_merge_or_retain_explicit_conflicts() { | ||
| 469 | let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); | ||
| 470 | let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one"); | ||
| 471 | let store = Store::parse(source).unwrap(); | ||
| 472 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 473 | let document = Document::parse(&index).unwrap(); | ||
| 474 | let mut server = Server::new(native); | ||
| 475 | let mut counts = [0; 3]; | ||
| 476 | let mut records = Vec::new(); | ||
| 477 | for (sid, page) in document.pages().unwrap() { | ||
| 478 | let space = &document.spaces[&sid]; | ||
| 479 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 480 | let Kind::Metadata { | ||
| 481 | title: Some(name), .. | ||
| 482 | } = &view.nodes[&view.roots[&2]].kind | ||
| 483 | else { | ||
| 484 | continue; | ||
| 485 | }; | ||
| 486 | if name == "Unicode rich text" { | ||
| 487 | continue; | ||
| 488 | } | ||
| 489 | let outlines: Vec<_> = view.nodes[&page] | ||
| 490 | .children | ||
| 491 | .iter() | ||
| 492 | .filter(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 493 | .copied() | ||
| 494 | .collect(); | ||
| 495 | if outlines.len() != 2 { | ||
| 496 | continue; | ||
| 497 | } | ||
| 498 | let outline = outlines[0]; | ||
| 499 | let mut pending = vec![outline]; | ||
| 500 | let mut target = None; | ||
| 501 | while let Some(id) = pending.pop() { | ||
| 502 | let node = &view.nodes[&id]; | ||
| 503 | pending.extend(&node.children); | ||
| 504 | if node.content.first().is_some_and(|text| matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text.starts_with("Target "))) { | ||
| 505 | target = Some(id); | ||
| 506 | } | ||
| 507 | } | ||
| 508 | let target = target.unwrap(); | ||
| 509 | let text_id = view.nodes[&view.nodes[&outlines[1]].children[0]].content[0]; | ||
| 510 | let (object, change, conflict) = match name.as_str() { | ||
| 511 | "Move outline" => ( | ||
| 512 | outline, | ||
| 513 | Change::Position { x: 252.0, y: 288.0 }, | ||
| 514 | Some(ConflictKind::LayoutChanged), | ||
| 515 | ), | ||
| 516 | "Resize outline" | "Automatic outline size" => ( | ||
| 517 | outline, | ||
| 518 | Change::Width { | ||
| 519 | points: 252.0, | ||
| 520 | user_set: true, | ||
| 521 | }, | ||
| 522 | Some(ConflictKind::LayoutChanged), | ||
| 523 | ), | ||
| 524 | "Delete outline" => ( | ||
| 525 | outline, | ||
| 526 | Change::Width { | ||
| 527 | points: 252.0, | ||
| 528 | user_set: true, | ||
| 529 | }, | ||
| 530 | Some(ConflictKind::TargetUnavailable), | ||
| 531 | ), | ||
| 532 | "Delete leaf" | "Delete subtree" | "Delete only paragraph" => ( | ||
| 533 | target, | ||
| 534 | Change::Collapsed(true), | ||
| 535 | Some(ConflictKind::TargetUnavailable), | ||
| 536 | ), | ||
| 537 | "Indent subtree" | "Outdent subtree" => ( | ||
| 538 | target, | ||
| 539 | Change::Collapsed(true), | ||
| 540 | Some(ConflictKind::StructureChanged), | ||
| 541 | ), | ||
| 542 | "Expand subtree" => (target, Change::Collapsed(false), None), | ||
| 543 | "Collapse subtree" | "Move leaf down" | "Move subtree down" | "Move subtree up" => { | ||
| 544 | (target, Change::Collapsed(true), None) | ||
| 545 | } | ||
| 546 | _ => panic!("Unexpected native case {name}"), | ||
| 547 | }; | ||
| 548 | let expected_layout = if conflict == Some(ConflictKind::TargetUnavailable) { | ||
| 549 | None | ||
| 550 | } else { | ||
| 551 | let mut layout = node(&server.visible, sid, object)["layout"].clone(); | ||
| 552 | match change { | ||
| 553 | Change::Position { x, y } => { | ||
| 554 | layout["x"] = x.into(); | ||
| 555 | layout["y"] = y.into(); | ||
| 556 | } | ||
| 557 | Change::Width { points, user_set } => { | ||
| 558 | layout["max_width"] = points.into(); | ||
| 559 | layout["width_set_by_user"] = user_set.into(); | ||
| 560 | } | ||
| 561 | Change::Collapsed(_) => {} | ||
| 562 | } | ||
| 563 | Some(layout) | ||
| 564 | }; | ||
| 565 | let directory = tempfile::tempdir().unwrap(); | ||
| 566 | let path = directory.path().join("cache.sqlite"); | ||
| 567 | let cache = Replica::create(&path, source).unwrap(); | ||
| 568 | let id = cache.outline(source, sid, object, change).unwrap().unwrap(); | ||
| 569 | let dependent = cache | ||
| 570 | .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ") | ||
| 571 | .unwrap() | ||
| 572 | .unwrap(); | ||
| 573 | let local = cache.snapshot().unwrap(); | ||
| 574 | let queue = cache.pending().unwrap(); | ||
| 575 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 576 | let mut retained = false; | ||
| 577 | if let Some(kind) = conflict { | ||
| 578 | assert_eq!(result, (id, EditStatus::Conflict(kind)), "{name}"); | ||
| 579 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 580 | assert!(cache.snapshot().unwrap() == local); | ||
| 581 | drop(cache); | ||
| 582 | let cache = Replica::open(&path).unwrap(); | ||
| 583 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Conflict(kind))); | ||
| 584 | if kind == ConflictKind::TargetUnavailable { | ||
| 585 | assert!( | ||
| 586 | cache | ||
| 587 | .rebase_layout_conflict(id, &local, &server.visible) | ||
| 588 | .is_err() | ||
| 589 | ); | ||
| 590 | counts[2] += 1; | ||
| 591 | retained = true; | ||
| 592 | } else { | ||
| 593 | cache | ||
| 594 | .rebase_layout_conflict(id, &local, &server.visible) | ||
| 595 | .unwrap(); | ||
| 596 | drop(cache); | ||
| 597 | let cache = Replica::open(&path).unwrap(); | ||
| 598 | assert!( | ||
| 599 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | ||
| 600 | ); | ||
| 601 | counts[1] += 1; | ||
| 602 | } | ||
| 603 | } else { | ||
| 604 | assert!( | ||
| 605 | matches!(result, (n, EditStatus::Published { .. }) if n == id), | ||
| 606 | "{name}" | ||
| 607 | ); | ||
| 608 | counts[0] += 1; | ||
| 609 | drop(cache); | ||
| 610 | } | ||
| 611 | let cache = Replica::open(&path).unwrap(); | ||
| 612 | if retained { | ||
| 613 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 614 | assert!(cache.snapshot().unwrap() == local); | ||
| 615 | } else { | ||
| 616 | assert!( | ||
| 617 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == dependent) | ||
| 618 | ); | ||
| 619 | assert_eq!( | ||
| 620 | node(&server.durable, sid, object)["layout"], | ||
| 621 | expected_layout.unwrap(), | ||
| 622 | "{name}" | ||
| 623 | ); | ||
| 624 | if matches!(change, Change::Collapsed(_)) { | ||
| 625 | assert_eq!( | ||
| 626 | node(&server.durable, sid, object)["kind"]["collapse_state"], | ||
| 627 | node(&local, sid, object)["kind"]["collapse_state"] | ||
| 628 | ); | ||
| 629 | } | ||
| 630 | assert!( | ||
| 631 | node(&server.durable, sid, text_id)["kind"]["text"] | ||
| 632 | .as_str() | ||
| 633 | .unwrap() | ||
| 634 | .starts_with("Local ") | ||
| 635 | ); | ||
| 636 | drop(cache); | ||
| 637 | let cache = Replica::open(&path).unwrap(); | ||
| 638 | for id in [id, dependent] { | ||
| 639 | assert!(matches!( | ||
| 640 | cache.status(id).unwrap(), | ||
| 641 | Some(EditStatus::Published { .. }) | ||
| 642 | )); | ||
| 643 | } | ||
| 644 | } | ||
| 645 | records.push(serde_json::json!({"name": name, "space": sid, "object": object, "change": change, "retained": retained, "dependent_text": text_id})); | ||
| 646 | } | ||
| 647 | assert_eq!(counts, [5, 5, 4]); | ||
| 648 | assert_eq!(server.publications, 18); | ||
| 649 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_OUTLINE_OUTPUT") { | ||
| 650 | let output = std::path::PathBuf::from(output); | ||
| 651 | assert!(output.is_absolute()); | ||
| 652 | std::fs::create_dir(&output).unwrap(); | ||
| 653 | std::fs::write(output.join("synthetic.one"), &server.durable).unwrap(); | ||
| 654 | std::fs::write( | ||
| 655 | output.with_extension("json"), | ||
| 656 | serde_json::to_vec_pretty(&records).unwrap(), | ||
| 657 | ) | ||
| 658 | .unwrap(); | ||
| 659 | } | ||
| 660 | } | ||
| 661 | |||
| 662 | #[test] | ||
| 663 | fn a_new_native_wrap_reservation_requires_review_before_width_replacement() { | ||
| 664 | let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); | ||
| 665 | let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one"); | ||
| 666 | let store = Store::parse(source).unwrap(); | ||
| 667 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 668 | let document = Document::parse(&index).unwrap(); | ||
| 669 | let (sid, page) = document.pages().unwrap().into_iter().find(|(sid, _)| { | ||
| 670 | let space = &document.spaces[sid]; | ||
| 671 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 672 | matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title: Some(name), .. } if name=="Move outline") | ||
| 673 | }).unwrap(); | ||
| 674 | let space = &document.spaces[&sid]; | ||
| 675 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 676 | let outline = *view.nodes[&page] | ||
| 677 | .children | ||
| 678 | .iter() | ||
| 679 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 680 | .unwrap(); | ||
| 681 | let old = node(source, sid, outline); | ||
| 682 | let remote = node(native, sid, outline); | ||
| 683 | assert_eq!(old["layout"]["max_width"], remote["layout"]["max_width"]); | ||
| 684 | assert_eq!( | ||
| 685 | old["layout"]["width_set_by_user"], | ||
| 686 | remote["layout"]["width_set_by_user"] | ||
| 687 | ); | ||
| 688 | assert!(remote["layout"]["reserved_width"].is_number()); | ||
| 689 | let directory = tempfile::tempdir().unwrap(); | ||
| 690 | let cache = Replica::create(directory.path().join("cache.sqlite"), source).unwrap(); | ||
| 691 | let id = cache | ||
| 692 | .outline( | ||
| 693 | source, | ||
| 694 | sid, | ||
| 695 | outline, | ||
| 696 | Change::Width { | ||
| 697 | points: 144.0, | ||
| 698 | user_set: true, | ||
| 699 | }, | ||
| 700 | ) | ||
| 701 | .unwrap() | ||
| 702 | .unwrap(); | ||
| 703 | let mut server = Server::new(native); | ||
| 704 | assert_eq!( | ||
| 705 | cache.sync_once(&mut server).unwrap(), | ||
| 706 | Some((id, EditStatus::Conflict(ConflictKind::LayoutChanged))) | ||
| 707 | ); | ||
| 708 | cache | ||
| 709 | .rebase_layout_conflict(id, &cache.snapshot().unwrap(), &server.visible) | ||
| 710 | .unwrap(); | ||
| 711 | assert!( | ||
| 712 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) | ||
| 713 | ); | ||
| 714 | let after = node(&server.durable, sid, outline); | ||
| 715 | assert_eq!(after["layout"]["x"], remote["layout"]["x"]); | ||
| 716 | assert_eq!(after["layout"]["y"], remote["layout"]["y"]); | ||
| 717 | assert_eq!(after["layout"]["max_width"], 144.0); | ||
| 718 | assert!(after["layout"]["reserved_width"].is_null()); | ||
| 719 | } | ||
crates/notebook/tests/sync/page.rs created+350| ... | @@ -0,0 +1,350 @@ | ||
| 1 | use super::*; | ||
| 2 | use notebook::{Operation, Recovery}; | ||
| 3 | use onestore::{Insertion, PageCreation}; | ||
| 4 | use std::collections::BTreeMap; | ||
| 5 | |||
| 6 | #[test] | ||
| 7 | fn twelve_replica_page_schedules_retain_acknowledged_pages_through_interruptions() { | ||
| 8 | for seed in 0..24_u64 { | ||
| 9 | let mut random = seed + 1956; | ||
| 10 | let mut input = Vec::new(); | ||
| 11 | for step in 0..48 { | ||
| 12 | random ^= random << 13; | ||
| 13 | random ^= random >> 7; | ||
| 14 | random ^= random << 17; | ||
| 15 | let mut action = random.to_le_bytes(); | ||
| 16 | if step < 12 { | ||
| 17 | action[0] = step; | ||
| 18 | action[1] = 1; | ||
| 19 | } | ||
| 20 | input.extend_from_slice(&action); | ||
| 21 | } | ||
| 22 | page_schedule::run(&input); | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | #[test] | ||
| 27 | fn version_two_uncertain_text_migrates_without_replay_or_lost_receipts() { | ||
| 28 | let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); | ||
| 29 | let (sid, oid, _) = text(&source); | ||
| 30 | let directory = tempfile::tempdir().unwrap(); | ||
| 31 | let path = directory.path().join("legacy.sqlite"); | ||
| 32 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 33 | let id = cache | ||
| 34 | .edit_text(&source, sid, oid, 0..0, "Old ") | ||
| 35 | .unwrap() | ||
| 36 | .unwrap(); | ||
| 37 | let snapshot = cache.snapshot().unwrap(); | ||
| 38 | let pending = cache.pending().unwrap(); | ||
| 39 | let store = Store::parse(&snapshot).unwrap(); | ||
| 40 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 41 | let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; | ||
| 42 | drop(cache); | ||
| 43 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 44 | db.execute_batch( | ||
| 45 | "DROP TABLE edits; DROP TABLE assets; | ||
| 46 | CREATE TABLE edits ( | ||
| 47 | id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), space TEXT NOT NULL, | ||
| 48 | object TEXT NOT NULL, before_text TEXT NOT NULL, | ||
| 49 | start INTEGER NOT NULL CHECK(start BETWEEN 0 AND 4294967295), | ||
| 50 | end INTEGER NOT NULL CHECK(end BETWEEN start AND 4294967295), replacement TEXT NOT NULL | ||
| 51 | ) STRICT; | ||
| 52 | ALTER TABLE attempt RENAME COLUMN revisions TO revision; | ||
| 53 | PRAGMA user_version=2;", | ||
| 54 | ) | ||
| 55 | .unwrap(); | ||
| 56 | db.execute( | ||
| 57 | "INSERT INTO edits VALUES (?1,?2,?3,'Original',0,0,'Old ')", | ||
| 58 | rusqlite::params![i64::try_from(id).unwrap(), sid.to_string(), oid.to_string()], | ||
| 59 | ) | ||
| 60 | .unwrap(); | ||
| 61 | db.execute( | ||
| 62 | "INSERT INTO attempt VALUES (1,?1,?2)", | ||
| 63 | rusqlite::params![i64::try_from(id).unwrap(), revision.to_string()], | ||
| 64 | ) | ||
| 65 | .unwrap(); | ||
| 66 | db.execute( | ||
| 67 | "INSERT INTO receipts VALUES (1000,?1)", | ||
| 68 | [revision.to_string()], | ||
| 69 | ) | ||
| 70 | .unwrap(); | ||
| 71 | db.execute("UPDATE sqlite_sequence SET seq=1000 WHERE name='edits'", []) | ||
| 72 | .unwrap(); | ||
| 73 | drop(db); | ||
| 74 | let cache = Replica::open(&path).unwrap(); | ||
| 75 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 76 | assert_eq!( | ||
| 77 | cache.status(id).unwrap(), | ||
| 78 | Some(EditStatus::AwaitingConfirmation { revision }) | ||
| 79 | ); | ||
| 80 | assert_eq!( | ||
| 81 | cache.status(1000).unwrap(), | ||
| 82 | Some(EditStatus::Published { revision }) | ||
| 83 | ); | ||
| 84 | assert!(cache.snapshot().unwrap() == snapshot); | ||
| 85 | let mut server = Server::new(&snapshot); | ||
| 86 | assert_eq!( | ||
| 87 | cache.sync_once(&mut server).unwrap(), | ||
| 88 | Some((id, EditStatus::Published { revision })) | ||
| 89 | ); | ||
| 90 | assert_eq!(server.publications, 0); | ||
| 91 | assert_eq!(server.confirmations, 1); | ||
| 92 | assert_eq!( | ||
| 93 | cache | ||
| 94 | .edit_text(&cache.snapshot().unwrap(), sid, oid, 0..0, "Next ") | ||
| 95 | .unwrap(), | ||
| 96 | Some(1001) | ||
| 97 | ); | ||
| 98 | } | ||
| 99 | |||
| 100 | #[test] | ||
| 101 | fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() { | ||
| 102 | let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); | ||
| 103 | let directory = tempfile::tempdir().unwrap(); | ||
| 104 | let mut server = Server::new(&source); | ||
| 105 | let mut expected = Vec::new(); | ||
| 106 | for actor in 0..12 { | ||
| 107 | let path = directory.path().join(format!("{actor}.sqlite")); | ||
| 108 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 109 | let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap(); | ||
| 110 | let id = cache.create_page(&source, &page).unwrap().unwrap(); | ||
| 111 | let insertion = Insertion::outline(page.object(), 36.0, 36.0, "Body", "Author").unwrap(); | ||
| 112 | cache | ||
| 113 | .insert(&cache.snapshot().unwrap(), page.space(), &insertion) | ||
| 114 | .unwrap() | ||
| 115 | .unwrap(); | ||
| 116 | cache | ||
| 117 | .edit_text( | ||
| 118 | &cache.snapshot().unwrap(), | ||
| 119 | page.space(), | ||
| 120 | insertion.text_object(), | ||
| 121 | 4..4, | ||
| 122 | &format!(" {actor}"), | ||
| 123 | ) | ||
| 124 | .unwrap() | ||
| 125 | .unwrap(); | ||
| 126 | let original = cache.pending().unwrap(); | ||
| 127 | assert!( | ||
| 128 | matches!(&original[0].operation, Operation::CreatePage(retained) if retained == &page) | ||
| 129 | ); | ||
| 130 | let local = cache.snapshot().unwrap(); | ||
| 131 | drop(cache); | ||
| 132 | let cache = Replica::open(&path).unwrap(); | ||
| 133 | assert_eq!(cache.pending().unwrap(), original); | ||
| 134 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 135 | for edit in original { | ||
| 136 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 137 | Some((published, EditStatus::Published { .. })) if published == edit.id)); | ||
| 138 | } | ||
| 139 | assert!(matches!( | ||
| 140 | cache.status(id).unwrap(), | ||
| 141 | Some(EditStatus::Published { .. }) | ||
| 142 | )); | ||
| 143 | expected.push(( | ||
| 144 | page.space(), | ||
| 145 | page.object(), | ||
| 146 | insertion.text_object(), | ||
| 147 | format!("Body {actor}"), | ||
| 148 | )); | ||
| 149 | } | ||
| 150 | let store = Store::parse(&server.durable).unwrap(); | ||
| 151 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 152 | let document = Document::parse(&index).unwrap(); | ||
| 153 | let pages = document.pages().unwrap(); | ||
| 154 | assert_eq!(pages.len(), 13); | ||
| 155 | for (actual, (sid, page, text, expected)) in pages[1..].iter().zip(&expected) { | ||
| 156 | assert_eq!(*actual, (*sid, *page)); | ||
| 157 | let space = &document.spaces[sid]; | ||
| 158 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 159 | assert!(matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text == expected)); | ||
| 160 | } | ||
| 161 | assert_eq!(server.publications, 36); | ||
| 162 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_OUTPUT") { | ||
| 163 | std::fs::create_dir(&output).unwrap(); | ||
| 164 | std::fs::write( | ||
| 165 | std::path::Path::new(&output).join("pages.one"), | ||
| 166 | &server.durable, | ||
| 167 | ) | ||
| 168 | .unwrap(); | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | #[test] | ||
| 173 | fn uncertain_page_publication_retains_both_revisions_and_never_replays() { | ||
| 174 | for fault in [ | ||
| 175 | Fault::UnknownBefore, | ||
| 176 | Fault::UnknownAfter, | ||
| 177 | Fault::PanicBefore, | ||
| 178 | Fault::PanicAfter, | ||
| 179 | ] { | ||
| 180 | let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); | ||
| 181 | let directory = tempfile::tempdir().unwrap(); | ||
| 182 | let path = directory.path().join("pages.sqlite"); | ||
| 183 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 184 | let page = PageCreation::new(None, Some("Created"), "Author").unwrap(); | ||
| 185 | let id = cache.create_page(&source, &page).unwrap().unwrap(); | ||
| 186 | let local = cache.snapshot().unwrap(); | ||
| 187 | let mut server = Server::new(&source); | ||
| 188 | server.fault = fault; | ||
| 189 | let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | ||
| 190 | cache.sync_once(&mut server) | ||
| 191 | })); | ||
| 192 | let attempted = cache.status(id).unwrap().unwrap(); | ||
| 193 | assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. })); | ||
| 194 | let archive = directory.path().join("recovery.sqlite"); | ||
| 195 | cache.export_recovery(&archive).unwrap(); | ||
| 196 | assert_eq!( | ||
| 197 | Recovery::open(&archive).unwrap().status(id).unwrap(), | ||
| 198 | Some(attempted) | ||
| 199 | ); | ||
| 200 | drop(cache); | ||
| 201 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 202 | let encoded: String = db | ||
| 203 | .query_row("SELECT revisions FROM attempt", [], |row| row.get(0)) | ||
| 204 | .unwrap(); | ||
| 205 | let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); | ||
| 206 | assert_eq!(revisions.len(), 2); | ||
| 207 | assert!(revisions.contains_key(&page.space())); | ||
| 208 | drop(db); | ||
| 209 | let cache = Replica::open(&path).unwrap(); | ||
| 210 | let observed = server.visible.clone(); | ||
| 211 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 212 | let visible = matches!(fault, Fault::UnknownAfter | Fault::PanicAfter); | ||
| 213 | assert_eq!(server.publications, 1); | ||
| 214 | assert_eq!(server.confirmations, usize::from(visible)); | ||
| 215 | if visible { | ||
| 216 | assert!(matches!(result.1, EditStatus::Published { .. })); | ||
| 217 | assert!(cache.snapshot().unwrap() == observed); | ||
| 218 | assert!(observed[..212] == server.durable[..212]); | ||
| 219 | assert!(observed[252..] == server.durable[252..]); | ||
| 220 | } else { | ||
| 221 | assert_eq!(result, (id, attempted)); | ||
| 222 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 223 | } | ||
| 224 | } | ||
| 225 | } | ||
| 226 | |||
| 227 | #[test] | ||
| 228 | fn surviving_page_revision_alone_does_not_confirm_section_publication() { | ||
| 229 | let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); | ||
| 230 | let directory = tempfile::tempdir().unwrap(); | ||
| 231 | let path = directory.path().join("pages.sqlite"); | ||
| 232 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 233 | let page = PageCreation::new(None, None, "Author").unwrap(); | ||
| 234 | let id = cache.create_page(&source, &page).unwrap().unwrap(); | ||
| 235 | let mut server = Server::new(&source); | ||
| 236 | server.fault = Fault::UnknownAfter; | ||
| 237 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 238 | let attempted = cache.status(id).unwrap().unwrap(); | ||
| 239 | drop(cache); | ||
| 240 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 241 | let encoded: String = db | ||
| 242 | .query_row("SELECT revisions FROM attempt", [], |row| row.get(0)) | ||
| 243 | .unwrap(); | ||
| 244 | let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); | ||
| 245 | drop(db); | ||
| 246 | let complete = server.visible.clone(); | ||
| 247 | let (&section, &retired) = revisions | ||
| 248 | .iter() | ||
| 249 | .find(|(sid, _)| **sid != page.space()) | ||
| 250 | .unwrap(); | ||
| 251 | let store = Store::parse(&complete).unwrap(); | ||
| 252 | let mut positions = Vec::new(); | ||
| 253 | for list in store.lists.values() { | ||
| 254 | for node in &list.nodes { | ||
| 255 | if node.id == 0x1e | ||
| 256 | && node.payload[..16] == retired.guid | ||
| 257 | && node.payload[16..20] == retired.n.to_le_bytes() | ||
| 258 | { | ||
| 259 | positions.push(node.offset); | ||
| 260 | } | ||
| 261 | } | ||
| 262 | } | ||
| 263 | assert_eq!(positions.len(), 1); | ||
| 264 | // Replacing only the revision identity models maintenance retiring the section proof. | ||
| 265 | let at = positions[0] + 4; | ||
| 266 | server.visible[at] ^= 0x40; | ||
| 267 | let store = Store::parse(&server.visible).unwrap(); | ||
| 268 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 269 | index.validate_current().unwrap(); | ||
| 270 | assert!(!index.spaces[&section].revisions.contains_key(&retired)); | ||
| 271 | assert!( | ||
| 272 | index.spaces[&page.space()] | ||
| 273 | .revisions | ||
| 274 | .contains_key(&revisions[&page.space()]) | ||
| 275 | ); | ||
| 276 | let cache = Replica::open(&path).unwrap(); | ||
| 277 | assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, attempted))); | ||
| 278 | assert_eq!(server.confirmations, 0); | ||
| 279 | assert_eq!(server.publications, 1); | ||
| 280 | server.visible = complete; | ||
| 281 | assert!(matches!( | ||
| 282 | cache.sync_once(&mut server).unwrap(), | ||
| 283 | Some((_, EditStatus::Published { .. })) | ||
| 284 | )); | ||
| 285 | assert_eq!(server.publications, 1); | ||
| 286 | } | ||
| 287 | |||
| 288 | #[test] | ||
| 289 | fn changed_page_anchor_requires_review_without_regenerating_dependent_identities() { | ||
| 290 | let source = | ||
| 291 | include_bytes!("../../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one"); | ||
| 292 | let remote = | ||
| 293 | include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); | ||
| 294 | let store = Store::parse(source).unwrap(); | ||
| 295 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 296 | let pages = Document::parse(&index).unwrap().pages().unwrap(); | ||
| 297 | let directory = tempfile::tempdir().unwrap(); | ||
| 298 | let path = directory.path().join("pages.sqlite"); | ||
| 299 | let cache = Replica::create(&path, source).unwrap(); | ||
| 300 | let page = PageCreation::new(Some(pages[4].0), Some("Created"), "Author").unwrap(); | ||
| 301 | let id = cache.create_page(source, &page).unwrap().unwrap(); | ||
| 302 | let insertion = | ||
| 303 | Insertion::outline(page.object(), 36.0, 36.0, "Retained body", "Author").unwrap(); | ||
| 304 | cache | ||
| 305 | .insert(&cache.snapshot().unwrap(), page.space(), &insertion) | ||
| 306 | .unwrap() | ||
| 307 | .unwrap(); | ||
| 308 | let local = cache.snapshot().unwrap(); | ||
| 309 | let pending = cache.pending().unwrap(); | ||
| 310 | let mut server = Server::new(remote); | ||
| 311 | assert_eq!( | ||
| 312 | cache.sync_once(&mut server).unwrap(), | ||
| 313 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | ||
| 314 | ); | ||
| 315 | assert!( | ||
| 316 | cache | ||
| 317 | .rebase_page_creation_conflict(id, source, remote, None) | ||
| 318 | .is_err() | ||
| 319 | ); | ||
| 320 | assert!( | ||
| 321 | cache | ||
| 322 | .rebase_page_creation_conflict(id, &local, source, None) | ||
| 323 | .is_err() | ||
| 324 | ); | ||
| 325 | cache | ||
| 326 | .rebase_page_creation_conflict(id, &local, remote, None) | ||
| 327 | .unwrap(); | ||
| 328 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 329 | assert_eq!(cache.pending().unwrap()[1], pending[1]); | ||
| 330 | let Operation::CreatePage(reviewed) = &cache.pending().unwrap()[0].operation else { | ||
| 331 | panic!() | ||
| 332 | }; | ||
| 333 | assert_eq!(*reviewed, page.reposition(None).unwrap()); | ||
| 334 | drop(cache); | ||
| 335 | let cache = Replica::open(&path).unwrap(); | ||
| 336 | for _ in 0..2 { | ||
| 337 | assert!(matches!( | ||
| 338 | cache.sync_once(&mut server).unwrap(), | ||
| 339 | Some((_, EditStatus::Published { .. })) | ||
| 340 | )); | ||
| 341 | } | ||
| 342 | assert_eq!(server.publications, 2); | ||
| 343 | let store = Store::parse(&server.durable).unwrap(); | ||
| 344 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 345 | let document = Document::parse(&index).unwrap(); | ||
| 346 | let space = &document.spaces[&page.space()]; | ||
| 347 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 348 | assert!(matches!(&view.nodes[&insertion.text_object()].kind, | ||
| 349 | Kind::RichText { text, .. } if text == "Retained body")); | ||
| 350 | } | ||
crates/notebook/tests/sync/pages.rs created+631| ... | @@ -0,0 +1,631 @@ | ||
| 1 | use super::*; | ||
| 2 | use notebook::{Operation, Recovery}; | ||
| 3 | use onestore::{PageEdit, PagePosition}; | ||
| 4 | use std::collections::BTreeMap; | ||
| 5 | |||
| 6 | const SOURCE: &[u8] = | ||
| 7 | include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); | ||
| 8 | |||
| 9 | fn order(source: &[u8]) -> Vec<(ExGuid, u32)> { | ||
| 10 | let store = Store::parse(source).unwrap(); | ||
| 11 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 12 | index.validate_current().unwrap(); | ||
| 13 | let document = Document::parse(&index).unwrap(); | ||
| 14 | document | ||
| 15 | .pages() | ||
| 16 | .unwrap() | ||
| 17 | .iter() | ||
| 18 | .map(|(sid, _)| { | ||
| 19 | let space = &document.spaces[sid]; | ||
| 20 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 21 | let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else { | ||
| 22 | panic!() | ||
| 23 | }; | ||
| 24 | (*sid, level.unwrap_or(1)) | ||
| 25 | }) | ||
| 26 | .collect() | ||
| 27 | } | ||
| 28 | |||
| 29 | fn texts(source: &[u8]) -> BTreeMap<(ExGuid, ExGuid), String> { | ||
| 30 | let store = Store::parse(source).unwrap(); | ||
| 31 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 32 | let document = Document::parse(&index).unwrap(); | ||
| 33 | let mut texts = BTreeMap::new(); | ||
| 34 | for (sid, space) in &document.spaces { | ||
| 35 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 36 | for (oid, node) in &view.nodes { | ||
| 37 | if let Kind::RichText { text, .. } = &node.kind { | ||
| 38 | texts.insert((*sid, *oid), text.clone()); | ||
| 39 | } | ||
| 40 | } | ||
| 41 | } | ||
| 42 | texts | ||
| 43 | } | ||
| 44 | |||
| 45 | #[test] | ||
| 46 | fn atomic_page_batches_survive_reopen_with_dependent_text() { | ||
| 47 | let directory = tempfile::tempdir().unwrap(); | ||
| 48 | let path = directory.path().join("pages.sqlite"); | ||
| 49 | let cache = Replica::create(&path, SOURCE).unwrap(); | ||
| 50 | let before = order(SOURCE); | ||
| 51 | let edits: Vec<_> = before[3..6] | ||
| 52 | .iter() | ||
| 53 | .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap()) | ||
| 54 | .collect(); | ||
| 55 | let id = cache.pages(SOURCE, &edits).unwrap().unwrap(); | ||
| 56 | assert!(cache.pages(SOURCE, &edits).is_err()); | ||
| 57 | let mut expected_text = texts(SOURCE); | ||
| 58 | let (&(sid, oid), original) = expected_text | ||
| 59 | .iter() | ||
| 60 | .find(|(_, text)| text.starts_with("Body parent")) | ||
| 61 | .unwrap(); | ||
| 62 | let changed = format!("Offline {original}"); | ||
| 63 | expected_text.insert((sid, oid), changed); | ||
| 64 | cache | ||
| 65 | .edit_text(&cache.snapshot().unwrap(), sid, oid, 0..0, "Offline ") | ||
| 66 | .unwrap() | ||
| 67 | .unwrap(); | ||
| 68 | let queue = cache.pending().unwrap(); | ||
| 69 | assert!(matches!(&queue[0].operation, Operation::Pages(batch) if batch.edits == edits)); | ||
| 70 | let local = cache.snapshot().unwrap(); | ||
| 71 | drop(cache); | ||
| 72 | let cache = Replica::open(&path).unwrap(); | ||
| 73 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 74 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 75 | let mut server = Server::new(SOURCE); | ||
| 76 | for edit in queue { | ||
| 77 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 78 | Some((published, EditStatus::Published { .. })) if published == edit.id)); | ||
| 79 | } | ||
| 80 | let expected = [ | ||
| 81 | before[..3].to_vec(), | ||
| 82 | before[6..].to_vec(), | ||
| 83 | before[3..6].to_vec(), | ||
| 84 | ] | ||
| 85 | .concat(); | ||
| 86 | assert_eq!(order(&server.durable), expected); | ||
| 87 | assert_eq!(texts(&server.durable), expected_text); | ||
| 88 | assert!(matches!( | ||
| 89 | cache.status(id).unwrap(), | ||
| 90 | Some(EditStatus::Published { .. }) | ||
| 91 | )); | ||
| 92 | assert_eq!(server.publications, 2); | ||
| 93 | } | ||
| 94 | |||
| 95 | #[test] | ||
| 96 | fn indentation_only_edits_preserve_remote_page_movement() { | ||
| 97 | let directory = tempfile::tempdir().unwrap(); | ||
| 98 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | ||
| 99 | let before = order(SOURCE); | ||
| 100 | let sid = before[4].0; | ||
| 101 | let id = cache | ||
| 102 | .pages(SOURCE, &[PageEdit::set_level(sid, 3).unwrap()]) | ||
| 103 | .unwrap() | ||
| 104 | .unwrap(); | ||
| 105 | let mut server = Server::new(SOURCE); | ||
| 106 | PreparedEdit::pages( | ||
| 107 | SOURCE, | ||
| 108 | &[PageEdit::move_to(sid, Some(before[7].0), 2).unwrap()], | ||
| 109 | ) | ||
| 110 | .unwrap() | ||
| 111 | .commit(&mut server) | ||
| 112 | .unwrap(); | ||
| 113 | let mut expected = order(&server.durable); | ||
| 114 | expected.iter_mut().find(|p| p.0 == sid).unwrap().1 = 3; | ||
| 115 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 116 | Some((published, EditStatus::Published { .. })) if published == id)); | ||
| 117 | assert_eq!(order(&server.durable), expected); | ||
| 118 | } | ||
| 119 | |||
| 120 | #[test] | ||
| 121 | fn competing_page_moves_require_current_review_and_retain_intent_identities() { | ||
| 122 | let directory = tempfile::tempdir().unwrap(); | ||
| 123 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | ||
| 124 | let pages = order(SOURCE); | ||
| 125 | let sid = pages[6].0; | ||
| 126 | let original = PageEdit::move_to(sid, None, 1).unwrap(); | ||
| 127 | let id = cache | ||
| 128 | .pages(SOURCE, std::slice::from_ref(&original)) | ||
| 129 | .unwrap() | ||
| 130 | .unwrap(); | ||
| 131 | let mut server = Server::new(SOURCE); | ||
| 132 | PreparedEdit::pages( | ||
| 133 | SOURCE, | ||
| 134 | &[PageEdit::move_to(sid, Some(pages[0].0), 1).unwrap()], | ||
| 135 | ) | ||
| 136 | .unwrap() | ||
| 137 | .commit(&mut server) | ||
| 138 | .unwrap(); | ||
| 139 | let remote = server.visible.clone(); | ||
| 140 | let local = cache.snapshot().unwrap(); | ||
| 141 | assert_eq!( | ||
| 142 | cache.sync_once(&mut server).unwrap(), | ||
| 143 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | ||
| 144 | ); | ||
| 145 | assert_eq!(server.publications, 0); | ||
| 146 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 147 | let revised = original | ||
| 148 | .reposition(PagePosition::Before(Some(pages[2].0)), 1) | ||
| 149 | .unwrap(); | ||
| 150 | let fresh = PageEdit::move_to(sid, Some(pages[2].0), 1).unwrap(); | ||
| 151 | assert!( | ||
| 152 | cache | ||
| 153 | .rebase_pages_conflict(id, &local, &remote, &[fresh]) | ||
| 154 | .is_err() | ||
| 155 | ); | ||
| 156 | assert!( | ||
| 157 | cache | ||
| 158 | .rebase_pages_conflict(id, SOURCE, &remote, std::slice::from_ref(&revised)) | ||
| 159 | .is_err() | ||
| 160 | ); | ||
| 161 | assert!( | ||
| 162 | cache | ||
| 163 | .rebase_pages_conflict(id, &local, SOURCE, std::slice::from_ref(&revised)) | ||
| 164 | .is_err() | ||
| 165 | ); | ||
| 166 | cache | ||
| 167 | .rebase_pages_conflict(id, &local, &remote, std::slice::from_ref(&revised)) | ||
| 168 | .unwrap(); | ||
| 169 | assert!(matches!(&cache.pending().unwrap()[0].operation, | ||
| 170 | Operation::Pages(batch) if batch.edits == [revised.clone()])); | ||
| 171 | let expected = PreparedEdit::pages(&remote, &[revised]).unwrap(); | ||
| 172 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 173 | Some((published, EditStatus::Published { .. })) if published == id)); | ||
| 174 | assert_eq!(order(&server.durable), order(expected.as_bytes())); | ||
| 175 | } | ||
| 176 | |||
| 177 | #[test] | ||
| 178 | fn one_competing_level_prevents_publication_of_the_whole_batch() { | ||
| 179 | let directory = tempfile::tempdir().unwrap(); | ||
| 180 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | ||
| 181 | let pages = order(SOURCE); | ||
| 182 | let id = cache | ||
| 183 | .pages( | ||
| 184 | SOURCE, | ||
| 185 | &[ | ||
| 186 | PageEdit::set_level(pages[4].0, 3).unwrap(), | ||
| 187 | PageEdit::set_level(pages[5].0, 2).unwrap(), | ||
| 188 | ], | ||
| 189 | ) | ||
| 190 | .unwrap() | ||
| 191 | .unwrap(); | ||
| 192 | let local = cache.snapshot().unwrap(); | ||
| 193 | let mut server = Server::new(SOURCE); | ||
| 194 | PreparedEdit::pages(SOURCE, &[PageEdit::set_level(pages[5].0, 1).unwrap()]) | ||
| 195 | .unwrap() | ||
| 196 | .commit(&mut server) | ||
| 197 | .unwrap(); | ||
| 198 | let remote = server.durable.clone(); | ||
| 199 | assert_eq!( | ||
| 200 | cache.sync_once(&mut server).unwrap(), | ||
| 201 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | ||
| 202 | ); | ||
| 203 | assert_eq!(server.publications, 0); | ||
| 204 | assert_eq!(server.durable, remote); | ||
| 205 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 206 | } | ||
| 207 | |||
| 208 | #[test] | ||
| 209 | fn uncertain_page_batches_survive_recovery_and_do_not_replay() { | ||
| 210 | for fault in [ | ||
| 211 | Fault::UnknownBefore, | ||
| 212 | Fault::UnknownAfter, | ||
| 213 | Fault::PanicBefore, | ||
| 214 | Fault::PanicAfter, | ||
| 215 | ] { | ||
| 216 | let directory = tempfile::tempdir().unwrap(); | ||
| 217 | let path = directory.path().join("pages.sqlite"); | ||
| 218 | let mut cache = Replica::create(&path, SOURCE).unwrap(); | ||
| 219 | let pages = order(SOURCE); | ||
| 220 | let edits = [ | ||
| 221 | PageEdit::set_level(pages[4].0, 3).unwrap(), | ||
| 222 | PageEdit::set_level(pages[5].0, 2).unwrap(), | ||
| 223 | ]; | ||
| 224 | let id = cache.pages(SOURCE, &edits).unwrap().unwrap(); | ||
| 225 | let local = cache.snapshot().unwrap(); | ||
| 226 | let queue = cache.pending().unwrap(); | ||
| 227 | let mut server = Server::new(SOURCE); | ||
| 228 | server.fault = fault; | ||
| 229 | let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | ||
| 230 | cache.sync_once(&mut server) | ||
| 231 | })); | ||
| 232 | drop(cache); | ||
| 233 | cache = Replica::open(&path).unwrap(); | ||
| 234 | let attempted = cache.status(id).unwrap().unwrap(); | ||
| 235 | assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. })); | ||
| 236 | let archive = directory.path().join("recovery.sqlite"); | ||
| 237 | cache.export_recovery(&archive).unwrap(); | ||
| 238 | let recovery = Recovery::open(&archive).unwrap(); | ||
| 239 | assert_eq!(recovery.pending().unwrap(), queue); | ||
| 240 | assert_eq!(recovery.status(id).unwrap(), Some(attempted)); | ||
| 241 | assert_eq!(recovery.snapshot().unwrap(), local); | ||
| 242 | assert!( | ||
| 243 | cache | ||
| 244 | .rebase_pages_conflict(id, &local, SOURCE, &edits) | ||
| 245 | .is_err() | ||
| 246 | ); | ||
| 247 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 248 | assert_eq!(server.publications, 1); | ||
| 249 | if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) { | ||
| 250 | assert!(matches!(result.1, EditStatus::Published { .. })); | ||
| 251 | assert_eq!(server.confirmations, 1); | ||
| 252 | } else { | ||
| 253 | assert_eq!(result, (id, attempted)); | ||
| 254 | assert_eq!(server.confirmations, 0); | ||
| 255 | } | ||
| 256 | } | ||
| 257 | } | ||
| 258 | |||
| 259 | #[test] | ||
| 260 | fn an_unchanged_section_revision_cannot_confirm_a_changed_page() { | ||
| 261 | let before = order(SOURCE); | ||
| 262 | let sid = before[4].0; | ||
| 263 | let store = Store::parse(SOURCE).unwrap(); | ||
| 264 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 265 | let root = index.root; | ||
| 266 | let doc = Document::parse(&index).unwrap(); | ||
| 267 | let space = &doc.spaces[&root]; | ||
| 268 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 269 | let copy = *view.nodes.iter().find(|(_, node)| | ||
| 270 | matches!(&node.kind, Kind::Metadata { title: Some(title), .. } if title == "child")) | ||
| 271 | .unwrap().0; | ||
| 272 | let source = | ||
| 273 | onestore::replace_property_bytes(SOURCE, root, copy, 0x14001dff, &3_u32.to_le_bytes()) | ||
| 274 | .unwrap(); | ||
| 275 | let directory = tempfile::tempdir().unwrap(); | ||
| 276 | let path = directory.path().join("pages.sqlite"); | ||
| 277 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 278 | let id = cache | ||
| 279 | .pages(&source, &[PageEdit::set_level(sid, 3).unwrap()]) | ||
| 280 | .unwrap() | ||
| 281 | .unwrap(); | ||
| 282 | let mut server = Server::new(&source); | ||
| 283 | server.fault = Fault::UnknownAfter; | ||
| 284 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 285 | let attempted = cache.status(id).unwrap().unwrap(); | ||
| 286 | drop(cache); | ||
| 287 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 288 | let encoded: String = db | ||
| 289 | .query_row("SELECT revisions FROM attempt", [], |r| r.get(0)) | ||
| 290 | .unwrap(); | ||
| 291 | let proofs: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); | ||
| 292 | assert_eq!(proofs.len(), 2); | ||
| 293 | let store = Store::parse(&source).unwrap(); | ||
| 294 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 295 | assert_eq!( | ||
| 296 | proofs[&root], | ||
| 297 | index.spaces[&root].labels[&(ExGuid::default(), 1)] | ||
| 298 | ); | ||
| 299 | assert!(!index.spaces[&sid].revisions.contains_key(&proofs[&sid])); | ||
| 300 | drop(db); | ||
| 301 | let cache = Replica::open(&path).unwrap(); | ||
| 302 | let complete = server.visible.clone(); | ||
| 303 | server.visible = source; | ||
| 304 | assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, attempted))); | ||
| 305 | assert_eq!(server.confirmations, 0); | ||
| 306 | server.visible = complete; | ||
| 307 | assert!(matches!( | ||
| 308 | cache.sync_once(&mut server).unwrap(), | ||
| 309 | Some((_, EditStatus::Published { .. })) | ||
| 310 | )); | ||
| 311 | assert_eq!(server.publications, 1); | ||
| 312 | assert_eq!(server.confirmations, 1); | ||
| 313 | } | ||
| 314 | |||
| 315 | #[test] | ||
| 316 | fn an_explicit_anchor_is_retained_even_when_originally_in_place() { | ||
| 317 | let directory = tempfile::tempdir().unwrap(); | ||
| 318 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | ||
| 319 | let pages = order(SOURCE); | ||
| 320 | let id = cache | ||
| 321 | .pages( | ||
| 322 | SOURCE, | ||
| 323 | &[ | ||
| 324 | PageEdit::move_to(pages[6].0, Some(pages[7].0), 1).unwrap(), | ||
| 325 | PageEdit::set_level(pages[4].0, 3).unwrap(), | ||
| 326 | ], | ||
| 327 | ) | ||
| 328 | .unwrap() | ||
| 329 | .unwrap(); | ||
| 330 | let mut server = Server::new(SOURCE); | ||
| 331 | PreparedEdit::pages( | ||
| 332 | SOURCE, | ||
| 333 | &[PageEdit::move_to(pages[6].0, Some(pages[0].0), 1).unwrap()], | ||
| 334 | ) | ||
| 335 | .unwrap() | ||
| 336 | .commit(&mut server) | ||
| 337 | .unwrap(); | ||
| 338 | assert_eq!( | ||
| 339 | cache.sync_once(&mut server).unwrap(), | ||
| 340 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | ||
| 341 | ); | ||
| 342 | assert_eq!(server.publications, 0); | ||
| 343 | } | ||
| 344 | |||
| 345 | #[test] | ||
| 346 | fn convergent_page_indentation_requires_confirmation_without_republishing() { | ||
| 347 | let directory = tempfile::tempdir().unwrap(); | ||
| 348 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | ||
| 349 | let sid = order(SOURCE)[4].0; | ||
| 350 | let id = cache | ||
| 351 | .pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()]) | ||
| 352 | .unwrap() | ||
| 353 | .unwrap(); | ||
| 354 | let mut server = Server::new(SOURCE); | ||
| 355 | PreparedEdit::pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()]) | ||
| 356 | .unwrap() | ||
| 357 | .commit(&mut server) | ||
| 358 | .unwrap(); | ||
| 359 | server.fault = Fault::Confirm; | ||
| 360 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 361 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 362 | assert_eq!(server.publications, 0); | ||
| 363 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 364 | Some((published, EditStatus::Published { .. })) if published == id)); | ||
| 365 | assert_eq!(server.publications, 0); | ||
| 366 | assert_eq!(server.confirmations, 2); | ||
| 367 | } | ||
| 368 | |||
| 369 | #[test] | ||
| 370 | fn schema_ten_migration_preserves_multi_space_uncertainty() { | ||
| 371 | let directory = tempfile::tempdir().unwrap(); | ||
| 372 | let path = directory.path().join("pages.sqlite"); | ||
| 373 | let cache = Replica::create(&path, SOURCE).unwrap(); | ||
| 374 | let page = onestore::PageCreation::new(None, Some("Migration"), "Author").unwrap(); | ||
| 375 | let id = cache.create_page(SOURCE, &page).unwrap().unwrap(); | ||
| 376 | let mut server = Server::new(SOURCE); | ||
| 377 | server.fault = Fault::UnknownBefore; | ||
| 378 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 379 | let local = cache.snapshot().unwrap(); | ||
| 380 | let queue = cache.pending().unwrap(); | ||
| 381 | let status = cache.status(id).unwrap(); | ||
| 382 | drop(cache); | ||
| 383 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 384 | db.pragma_update(None, "user_version", 10).unwrap(); | ||
| 385 | let proofs: String = db | ||
| 386 | .query_row("SELECT revisions FROM attempt", [], |r| r.get(0)) | ||
| 387 | .unwrap(); | ||
| 388 | drop(db); | ||
| 389 | let cache = Replica::open(&path).unwrap(); | ||
| 390 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 391 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 392 | assert_eq!(cache.status(id).unwrap(), status); | ||
| 393 | assert_eq!( | ||
| 394 | cache.sync_once(&mut server).unwrap(), | ||
| 395 | Some((id, status.unwrap())) | ||
| 396 | ); | ||
| 397 | assert_eq!(server.publications, 1); | ||
| 398 | drop(cache); | ||
| 399 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 400 | assert_eq!( | ||
| 401 | db.query_row("SELECT revisions FROM attempt", [], |r| r | ||
| 402 | .get::<_, String>(0)) | ||
| 403 | .unwrap(), | ||
| 404 | proofs | ||
| 405 | ); | ||
| 406 | assert_eq!( | ||
| 407 | db.pragma_query_value(None, "user_version", |r| r.get::<_, u32>(0)) | ||
| 408 | .unwrap(), | ||
| 409 | 11 | ||
| 410 | ); | ||
| 411 | } | ||
| 412 | |||
| 413 | #[test] | ||
| 414 | fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { | ||
| 415 | let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) | ||
| 416 | .join("../../corpus/page-lifecycle/movement"); | ||
| 417 | let provenance: serde_json::Value = | ||
| 418 | serde_json::from_slice(&std::fs::read(root.join("provenance.json")).unwrap()).unwrap(); | ||
| 419 | let phases = provenance["cold"].as_object().unwrap(); | ||
| 420 | assert_eq!(phases.len(), 9); | ||
| 421 | let original = order(SOURCE); | ||
| 422 | let original_text = texts(SOURCE); | ||
| 423 | let (&(text_space, text_object), body) = original_text | ||
| 424 | .iter() | ||
| 425 | .find(|(_, text)| text.starts_with("Body parent")) | ||
| 426 | .unwrap(); | ||
| 427 | let mut expected_text = original_text.clone(); | ||
| 428 | expected_text.insert((text_space, text_object), format!("Offline {body}")); | ||
| 429 | let mut counts = [0; 2]; | ||
| 430 | for mode in ["indent", "group"] { | ||
| 431 | for phase in phases.keys() { | ||
| 432 | let directory = tempfile::tempdir().unwrap(); | ||
| 433 | let path = directory.path().join("pages.sqlite"); | ||
| 434 | let cache = Replica::create(&path, SOURCE).unwrap(); | ||
| 435 | let edits = if mode == "indent" { | ||
| 436 | vec![PageEdit::set_level(original[4].0, 3).unwrap()] | ||
| 437 | } else { | ||
| 438 | original[3..6] | ||
| 439 | .iter() | ||
| 440 | .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap()) | ||
| 441 | .collect() | ||
| 442 | }; | ||
| 443 | let id = cache.pages(SOURCE, &edits).unwrap().unwrap(); | ||
| 444 | let text_id = cache | ||
| 445 | .edit_text( | ||
| 446 | &cache.snapshot().unwrap(), | ||
| 447 | text_space, | ||
| 448 | text_object, | ||
| 449 | 0..0, | ||
| 450 | "Offline ", | ||
| 451 | ) | ||
| 452 | .unwrap() | ||
| 453 | .unwrap(); | ||
| 454 | let native = std::fs::read(root.join(phase).join("notebook/Lifecycle.one")).unwrap(); | ||
| 455 | let mut server = Server::new(&native); | ||
| 456 | let local = cache.snapshot().unwrap(); | ||
| 457 | let expected_conflict = if mode == "indent" { | ||
| 458 | matches!( | ||
| 459 | phase.as_str(), | ||
| 460 | "04-subpage-move" | "07-promoted-root" | "08-collapsed-group-move" | ||
| 461 | ) | ||
| 462 | } else { | ||
| 463 | !matches!( | ||
| 464 | phase.as_str(), | ||
| 465 | "02-promoted-parent" | "03-selected-group-move" | "06-promoted-level-two" | ||
| 466 | ) | ||
| 467 | }; | ||
| 468 | let first = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 469 | assert_eq!(first.0, id); | ||
| 470 | let mut reviewed = edits.clone(); | ||
| 471 | if expected_conflict { | ||
| 472 | assert_eq!( | ||
| 473 | first.1, | ||
| 474 | EditStatus::Conflict(ConflictKind::StructureChanged), | ||
| 475 | "{mode}:{phase}" | ||
| 476 | ); | ||
| 477 | assert_eq!(server.publications, 0); | ||
| 478 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 479 | if mode == "indent" && phase == "08-collapsed-group-move" { | ||
| 480 | reviewed[0] = reviewed[0].reposition(PagePosition::Keep, 1).unwrap(); | ||
| 481 | } | ||
| 482 | cache | ||
| 483 | .rebase_pages_conflict(id, &local, &native, &reviewed) | ||
| 484 | .unwrap(); | ||
| 485 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 486 | Some((published, EditStatus::Published { .. })) if published == id)); | ||
| 487 | } else { | ||
| 488 | assert!( | ||
| 489 | matches!(first.1, EditStatus::Published { .. }), | ||
| 490 | "{mode}:{phase}" | ||
| 491 | ); | ||
| 492 | } | ||
| 493 | counts[usize::from(expected_conflict)] += 1; | ||
| 494 | drop(cache); | ||
| 495 | let cache = Replica::open(&path).unwrap(); | ||
| 496 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 497 | Some((published, EditStatus::Published { .. })) if published == text_id)); | ||
| 498 | assert!(cache.pending().unwrap().is_empty()); | ||
| 499 | let mut expected = order(&native); | ||
| 500 | for edit in &reviewed { | ||
| 501 | let at = expected.iter().position(|p| p.0 == edit.space()).unwrap(); | ||
| 502 | expected[at].1 = edit.level(); | ||
| 503 | if let PagePosition::Before(before) = edit.position() { | ||
| 504 | let page = expected.remove(at); | ||
| 505 | let at = before.map_or(expected.len(), |sid| { | ||
| 506 | expected.iter().position(|p| p.0 == sid).unwrap() | ||
| 507 | }); | ||
| 508 | expected.insert(at, page); | ||
| 509 | } | ||
| 510 | } | ||
| 511 | assert_eq!(order(&server.durable), expected, "{mode}:{phase}"); | ||
| 512 | assert_eq!(texts(&server.durable), expected_text, "{mode}:{phase}"); | ||
| 513 | assert_eq!( | ||
| 514 | server.publications, | ||
| 515 | 1 + usize::from(expected != order(&native)) | ||
| 516 | ); | ||
| 517 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_EDIT_OUTPUT") { | ||
| 518 | let output = std::path::Path::new(&output); | ||
| 519 | assert!(output.is_absolute()); | ||
| 520 | let path = output.join(format!("{mode}-{phase}")); | ||
| 521 | std::fs::create_dir_all(&path).unwrap(); | ||
| 522 | std::fs::write(path.join("Lifecycle.one"), &server.durable).unwrap(); | ||
| 523 | std::fs::write( | ||
| 524 | path.join("manifest.json"), | ||
| 525 | serde_json::to_vec_pretty( | ||
| 526 | &serde_json::json!({"mode": mode, "phase": phase, "original": edits, | ||
| 527 | "reviewed": reviewed, "conflict": expected_conflict, | ||
| 528 | "publications": server.publications, "confirmations": server.confirmations, | ||
| 529 | "page_receipt": format!("{:?}", cache.status(id).unwrap()), | ||
| 530 | "text_receipt": format!("{:?}", cache.status(text_id).unwrap())}), | ||
| 531 | ) | ||
| 532 | .unwrap(), | ||
| 533 | ) | ||
| 534 | .unwrap(); | ||
| 535 | } | ||
| 536 | } | ||
| 537 | } | ||
| 538 | assert_eq!(counts, [9, 9]); | ||
| 539 | } | ||
| 540 | |||
| 541 | #[test] | ||
| 542 | fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { | ||
| 543 | let mut source = onestore::create_section("pages.one", "Sentinel", "Author").unwrap(); | ||
| 544 | let mut created = Vec::new(); | ||
| 545 | for _ in 0..36 { | ||
| 546 | let page = onestore::PageCreation::new(None, Some("Same title"), "Author").unwrap(); | ||
| 547 | source = PreparedEdit::create_page(&source, &page) | ||
| 548 | .unwrap() | ||
| 549 | .as_bytes() | ||
| 550 | .to_vec(); | ||
| 551 | created.push(page); | ||
| 552 | } | ||
| 553 | let mut expected = vec![order(&source)[0]]; | ||
| 554 | let mut expected_text = texts(&source); | ||
| 555 | let directory = tempfile::tempdir().unwrap(); | ||
| 556 | let mut queues = Vec::new(); | ||
| 557 | for (actor, group) in created.chunks_exact(3).enumerate() { | ||
| 558 | let path = directory.path().join(format!("{actor}.sqlite")); | ||
| 559 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 560 | let edits = [ | ||
| 561 | PageEdit::move_to(group[1].space(), Some(group[0].space()), 1).unwrap(), | ||
| 562 | PageEdit::set_level(group[2].space(), 2).unwrap(), | ||
| 563 | ]; | ||
| 564 | cache.pages(&source, &edits).unwrap().unwrap(); | ||
| 565 | let body = format!("Actor {actor} 🦀 é"); | ||
| 566 | let insertion = | ||
| 567 | onestore::Insertion::outline(group[1].object(), 36.0, 36.0, &body, "Author").unwrap(); | ||
| 568 | cache | ||
| 569 | .insert(&cache.snapshot().unwrap(), group[1].space(), &insertion) | ||
| 570 | .unwrap() | ||
| 571 | .unwrap(); | ||
| 572 | expected.extend([ | ||
| 573 | (group[1].space(), 1), | ||
| 574 | (group[0].space(), 1), | ||
| 575 | (group[2].space(), 2), | ||
| 576 | ]); | ||
| 577 | expected_text.insert((group[1].space(), insertion.text_object()), body); | ||
| 578 | queues.push((path, cache.pending().unwrap())); | ||
| 579 | } | ||
| 580 | let mut server = Server::new(&source); | ||
| 581 | for (path, queue) in &queues { | ||
| 582 | let cache = Replica::open(path).unwrap(); | ||
| 583 | assert_eq!(cache.pending().unwrap(), *queue); | ||
| 584 | for edit in queue { | ||
| 585 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 586 | Some((published, EditStatus::Published { .. })) if published == edit.id)); | ||
| 587 | } | ||
| 588 | assert!(cache.pending().unwrap().is_empty()); | ||
| 589 | } | ||
| 590 | assert_eq!(server.publications, 24); | ||
| 591 | assert_eq!(order(&server.durable), expected); | ||
| 592 | assert_eq!(texts(&server.durable), expected_text); | ||
| 593 | for (path, queue) in &queues { | ||
| 594 | let cache = Replica::open(path).unwrap(); | ||
| 595 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | ||
| 596 | assert_eq!(order(&cache.snapshot().unwrap()), expected); | ||
| 597 | for edit in queue { | ||
| 598 | assert!(matches!( | ||
| 599 | cache.status(edit.id).unwrap(), | ||
| 600 | Some(EditStatus::Published { .. }) | ||
| 601 | )); | ||
| 602 | } | ||
| 603 | } | ||
| 604 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_CLIENT_OUTPUT") { | ||
| 605 | let output = std::path::Path::new(&output); | ||
| 606 | assert!(output.is_absolute()); | ||
| 607 | std::fs::create_dir_all(output).unwrap(); | ||
| 608 | std::fs::write(output.join("pages.one"), &server.durable).unwrap(); | ||
| 609 | let operations: Vec<_> = queues | ||
| 610 | .iter() | ||
| 611 | .map(|(_, queue)| { | ||
| 612 | queue | ||
| 613 | .iter() | ||
| 614 | .map(|edit| { | ||
| 615 | serde_json::json!({"id": edit.id, "space": edit.space, | ||
| 616 | "operation": edit.operation}) | ||
| 617 | }) | ||
| 618 | .collect::<Vec<_>>() | ||
| 619 | }) | ||
| 620 | .collect(); | ||
| 621 | std::fs::write( | ||
| 622 | output.join("manifest.json"), | ||
| 623 | serde_json::to_vec_pretty( | ||
| 624 | &serde_json::json!({"operations": operations, "publications": server.publications, | ||
| 625 | "expected_order": expected, "pages": 37}), | ||
| 626 | ) | ||
| 627 | .unwrap(), | ||
| 628 | ) | ||
| 629 | .unwrap(); | ||
| 630 | } | ||
| 631 | } | ||
crates/notebook/tests/sync/tree.rs created+821| ... | @@ -0,0 +1,821 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{Insertion, TreeEdit}; | ||
| 3 | |||
| 4 | #[test] | ||
| 5 | fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() { | ||
| 6 | let mut random = 1940_u64; | ||
| 7 | for _ in 0..60 { | ||
| 8 | let input: Vec<_> = (0..384) | ||
| 9 | .map(|_| { | ||
| 10 | random ^= random << 13; | ||
| 11 | random ^= random >> 7; | ||
| 12 | random ^= random << 17; | ||
| 13 | random as u8 | ||
| 14 | }) | ||
| 15 | .collect(); | ||
| 16 | tree_schedule::run(&input); | ||
| 17 | } | ||
| 18 | } | ||
| 19 | |||
| 20 | fn fixture() -> (Vec<u8>, ExGuid, ExGuid, [ExGuid; 4], [ExGuid; 4]) { | ||
| 21 | let (mut source, sid, outline, first, text) = super::outline::fixture(); | ||
| 22 | let mut paragraphs = [first; 4]; | ||
| 23 | let mut texts = [text; 4]; | ||
| 24 | for at in 1..4 { | ||
| 25 | let insertion = | ||
| 26 | Insertion::paragraph(outline, None, &format!("Sibling {at}"), "Author").unwrap(); | ||
| 27 | source = PreparedEdit::insert(&source, sid, &insertion) | ||
| 28 | .unwrap() | ||
| 29 | .as_bytes() | ||
| 30 | .to_vec(); | ||
| 31 | paragraphs[at] = insertion.object(); | ||
| 32 | texts[at] = insertion.text_object(); | ||
| 33 | } | ||
| 34 | (source, sid, outline, paragraphs, texts) | ||
| 35 | } | ||
| 36 | |||
| 37 | fn children(source: &[u8], sid: ExGuid, object: ExGuid) -> Vec<ExGuid> { | ||
| 38 | let store = Store::parse(source).unwrap(); | ||
| 39 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 40 | index.validate_current().unwrap(); | ||
| 41 | let document = Document::parse(&index).unwrap(); | ||
| 42 | let space = &document.spaces[&sid]; | ||
| 43 | space.revisions[&space.contexts[&ExGuid::default()]].nodes[&object] | ||
| 44 | .children | ||
| 45 | .clone() | ||
| 46 | } | ||
| 47 | |||
| 48 | #[test] | ||
| 49 | fn move_preserves_remote_content_and_dependent_edits_through_reopen() { | ||
| 50 | let (source, sid, outline, paragraphs, texts) = fixture(); | ||
| 51 | let directory = tempfile::tempdir().unwrap(); | ||
| 52 | let path = directory.path().join("cache.sqlite"); | ||
| 53 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 54 | let intent = TreeEdit::move_to(paragraphs[0], outline, None, "Offline").unwrap(); | ||
| 55 | let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); | ||
| 56 | let dependent = cache | ||
| 57 | .edit_text(&cache.snapshot().unwrap(), sid, texts[0], 0..0, "Local ") | ||
| 58 | .unwrap() | ||
| 59 | .unwrap(); | ||
| 60 | let local = cache.snapshot().unwrap(); | ||
| 61 | let queue = cache.pending().unwrap(); | ||
| 62 | drop(cache); | ||
| 63 | let cache = Replica::open(&path).unwrap(); | ||
| 64 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 65 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 66 | let edited = PreparedEdit::format( | ||
| 67 | &source, | ||
| 68 | sid, | ||
| 69 | texts[0], | ||
| 70 | 0..8, | ||
| 71 | &[onestore::TextAttribute::Bold(true)], | ||
| 72 | ) | ||
| 73 | .unwrap(); | ||
| 74 | let descendant = | ||
| 75 | Insertion::paragraph(paragraphs[0], None, "New remote child", "Remote").unwrap(); | ||
| 76 | let remote = PreparedEdit::insert(edited.as_bytes(), sid, &descendant).unwrap(); | ||
| 77 | let mut server = Server::new(remote.as_bytes()); | ||
| 78 | for expected in [id, dependent] { | ||
| 79 | assert!( | ||
| 80 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) | ||
| 81 | ); | ||
| 82 | } | ||
| 83 | assert_eq!( | ||
| 84 | children(&server.durable, sid, outline), | ||
| 85 | [paragraphs[1], paragraphs[2], paragraphs[3], paragraphs[0]] | ||
| 86 | ); | ||
| 87 | assert_eq!( | ||
| 88 | children(&server.durable, sid, paragraphs[0]), | ||
| 89 | [descendant.object()] | ||
| 90 | ); | ||
| 91 | let node = super::outline::node(&server.durable, sid, texts[0]); | ||
| 92 | assert_eq!(node["kind"]["text"], "Local Original 🦀 é"); | ||
| 93 | let store = Store::parse(&server.durable).unwrap(); | ||
| 94 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 95 | let document = Document::parse(&index).unwrap(); | ||
| 96 | let space = &document.spaces[&sid]; | ||
| 97 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 98 | assert_eq!(view.text_runs(texts[0]).unwrap()[0].format.bold, Some(true)); | ||
| 99 | assert!(cache.pending().unwrap().is_empty()); | ||
| 100 | assert_eq!(server.publications, 2); | ||
| 101 | } | ||
| 102 | |||
| 103 | #[test] | ||
| 104 | fn queued_format_split_and_delete_reconcile_equivalent_immutable_style_identities() { | ||
| 105 | let (source, sid, _, _, texts) = fixture(); | ||
| 106 | let directory = tempfile::tempdir().unwrap(); | ||
| 107 | let path = directory.path().join("cache.sqlite"); | ||
| 108 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 109 | cache | ||
| 110 | .format( | ||
| 111 | &source, | ||
| 112 | sid, | ||
| 113 | texts[0], | ||
| 114 | 0..8, | ||
| 115 | &[onestore::TextAttribute::Bold(true)], | ||
| 116 | ) | ||
| 117 | .unwrap(); | ||
| 118 | let split = onestore::ParagraphSplit::new(texts[0], 4, "Author").unwrap(); | ||
| 119 | cache | ||
| 120 | .split(&cache.snapshot().unwrap(), sid, &split) | ||
| 121 | .unwrap(); | ||
| 122 | cache | ||
| 123 | .tree( | ||
| 124 | &cache.snapshot().unwrap(), | ||
| 125 | sid, | ||
| 126 | &TreeEdit::delete(split.object(), "Author").unwrap(), | ||
| 127 | ) | ||
| 128 | .unwrap(); | ||
| 129 | cache | ||
| 130 | .edit_text(&cache.snapshot().unwrap(), sid, texts[0], 0..0, "Local ") | ||
| 131 | .unwrap(); | ||
| 132 | let queue = cache.pending().unwrap(); | ||
| 133 | drop(cache); | ||
| 134 | let mut server = Server::new(&source); | ||
| 135 | for intent in queue { | ||
| 136 | let cache = Replica::open(&path).unwrap(); | ||
| 137 | assert!( | ||
| 138 | matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == intent.id) | ||
| 139 | ); | ||
| 140 | } | ||
| 141 | assert_eq!( | ||
| 142 | super::outline::node(&server.durable, sid, texts[0])["kind"]["text"], | ||
| 143 | "Local Orig" | ||
| 144 | ); | ||
| 145 | } | ||
| 146 | |||
| 147 | #[test] | ||
| 148 | fn native_empty_child_list_normalization_preserves_deletion_and_its_dependents() { | ||
| 149 | let source = include_bytes!("../../../../corpus/outline-edit/empty-children/before.one"); | ||
| 150 | let remote = include_bytes!("../../../../corpus/outline-edit/empty-children/remote.one"); | ||
| 151 | let (sid, intent): (ExGuid, TreeEdit) = serde_json::from_str(include_str!( | ||
| 152 | "../../../../corpus/outline-edit/empty-children/intent.json" | ||
| 153 | )) | ||
| 154 | .unwrap(); | ||
| 155 | let store = Store::parse(source).unwrap(); | ||
| 156 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 157 | let document = Document::parse(&index).unwrap(); | ||
| 158 | let space = &document.spaces[&sid]; | ||
| 159 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 160 | let tail = view.nodes[&intent.object()].content[0]; | ||
| 161 | let (outline_id, outline) = view | ||
| 162 | .nodes | ||
| 163 | .iter() | ||
| 164 | .find(|(_, node)| node.children.contains(&intent.object())) | ||
| 165 | .unwrap(); | ||
| 166 | let left = view.nodes[&outline.children[0]].content[0]; | ||
| 167 | for changed in [false, true] { | ||
| 168 | let directory = tempfile::tempdir().unwrap(); | ||
| 169 | let path = directory.path().join("cache.sqlite"); | ||
| 170 | let cache = Replica::create(&path, source).unwrap(); | ||
| 171 | let deletion = cache.tree(source, sid, &intent).unwrap().unwrap(); | ||
| 172 | let dependent = cache | ||
| 173 | .edit_text(&cache.snapshot().unwrap(), sid, left, 0..0, "Local ") | ||
| 174 | .unwrap() | ||
| 175 | .unwrap(); | ||
| 176 | let local = cache.snapshot().unwrap(); | ||
| 177 | drop(cache); | ||
| 178 | let cache = Replica::open(&path).unwrap(); | ||
| 179 | let mut server = if changed { | ||
| 180 | let edited = PreparedEdit::text(remote, sid, tail, 0..2, "Changed").unwrap(); | ||
| 181 | Server::new(edited.as_bytes()) | ||
| 182 | } else { | ||
| 183 | Server::new(remote) | ||
| 184 | }; | ||
| 185 | if changed { | ||
| 186 | assert_eq!( | ||
| 187 | cache.sync_once(&mut server).unwrap(), | ||
| 188 | Some((deletion, EditStatus::Conflict(ConflictKind::ContentChanged))) | ||
| 189 | ); | ||
| 190 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 191 | assert_eq!(cache.pending().unwrap().len(), 2); | ||
| 192 | assert_eq!(server.publications, 0); | ||
| 193 | } else { | ||
| 194 | for expected in [deletion, dependent] { | ||
| 195 | assert!( | ||
| 196 | matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == expected) | ||
| 197 | ); | ||
| 198 | } | ||
| 199 | assert!(cache.pending().unwrap().is_empty()); | ||
| 200 | assert_eq!(server.publications, 2); | ||
| 201 | assert!(!children(&server.durable, sid, *outline_id).contains(&intent.object())); | ||
| 202 | let node = super::outline::node(&server.durable, sid, left); | ||
| 203 | assert!(node["kind"]["text"].as_str().unwrap().starts_with("Local ")); | ||
| 204 | } | ||
| 205 | } | ||
| 206 | } | ||
| 207 | |||
| 208 | #[test] | ||
| 209 | fn deletion_requires_review_of_remote_content_and_preserves_later_work() { | ||
| 210 | let (source, sid, outline, paragraphs, texts) = fixture(); | ||
| 211 | let child = Insertion::paragraph(paragraphs[0], None, "Descendant", "Author").unwrap(); | ||
| 212 | let source = PreparedEdit::insert(&source, sid, &child) | ||
| 213 | .unwrap() | ||
| 214 | .as_bytes() | ||
| 215 | .to_vec(); | ||
| 216 | for remote in [ | ||
| 217 | PreparedEdit::text(&source, sid, texts[0], 0..0, "Remote ") | ||
| 218 | .unwrap() | ||
| 219 | .as_bytes() | ||
| 220 | .to_vec(), | ||
| 221 | PreparedEdit::text(&source, sid, child.text_object(), 0..0, "Remote ") | ||
| 222 | .unwrap() | ||
| 223 | .as_bytes() | ||
| 224 | .to_vec(), | ||
| 225 | PreparedEdit::format( | ||
| 226 | &source, | ||
| 227 | sid, | ||
| 228 | child.text_object(), | ||
| 229 | 0..10, | ||
| 230 | &[onestore::TextAttribute::Italic(true)], | ||
| 231 | ) | ||
| 232 | .unwrap() | ||
| 233 | .as_bytes() | ||
| 234 | .to_vec(), | ||
| 235 | PreparedEdit::insert( | ||
| 236 | &source, | ||
| 237 | sid, | ||
| 238 | &Insertion::paragraph(paragraphs[0], None, "New child", "Remote").unwrap(), | ||
| 239 | ) | ||
| 240 | .unwrap() | ||
| 241 | .as_bytes() | ||
| 242 | .to_vec(), | ||
| 243 | ] { | ||
| 244 | let directory = tempfile::tempdir().unwrap(); | ||
| 245 | let path = directory.path().join("cache.sqlite"); | ||
| 246 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 247 | let intent = TreeEdit::delete(paragraphs[0], "Offline").unwrap(); | ||
| 248 | let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); | ||
| 249 | let dependent = cache | ||
| 250 | .edit_text(&cache.snapshot().unwrap(), sid, texts[1], 0..0, "Local ") | ||
| 251 | .unwrap() | ||
| 252 | .unwrap(); | ||
| 253 | let queue = cache.pending().unwrap(); | ||
| 254 | let local = cache.snapshot().unwrap(); | ||
| 255 | let mut server = Server::new(&remote); | ||
| 256 | assert_eq!( | ||
| 257 | cache.sync_once(&mut server).unwrap(), | ||
| 258 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | ||
| 259 | ); | ||
| 260 | assert_eq!(server.publications, 0); | ||
| 261 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 262 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 263 | assert!(cache.rebase_tree_conflict(id, &source, &remote).is_err()); | ||
| 264 | assert!(cache.rebase_tree_conflict(id, &local, &source).is_err()); | ||
| 265 | assert!( | ||
| 266 | cache | ||
| 267 | .rebase_tree_conflict(dependent, &local, &remote) | ||
| 268 | .is_err() | ||
| 269 | ); | ||
| 270 | assert!(cache.rebase_conflict(id, &local, &remote, 0..0).is_err()); | ||
| 271 | let archive = directory.path().join("recovery.sqlite"); | ||
| 272 | cache.export_recovery(&archive).unwrap(); | ||
| 273 | let recovery = notebook::Recovery::open(&archive).unwrap(); | ||
| 274 | assert_eq!(recovery.pending().unwrap(), queue); | ||
| 275 | assert_eq!(recovery.status(id).unwrap(), cache.status(id).unwrap()); | ||
| 276 | assert!(recovery.snapshot().unwrap() == local); | ||
| 277 | drop(cache); | ||
| 278 | let cache = Replica::open(&path).unwrap(); | ||
| 279 | assert_eq!( | ||
| 280 | cache.status(id).unwrap(), | ||
| 281 | Some(EditStatus::Conflict(ConflictKind::ContentChanged)) | ||
| 282 | ); | ||
| 283 | cache.rebase_tree_conflict(id, &local, &remote).unwrap(); | ||
| 284 | assert_eq!(cache.pending().unwrap()[1], queue[1]); | ||
| 285 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 286 | for expected in [id, dependent] { | ||
| 287 | assert!( | ||
| 288 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) | ||
| 289 | ); | ||
| 290 | } | ||
| 291 | assert_eq!(children(&server.durable, sid, outline), paragraphs[1..]); | ||
| 292 | assert_eq!( | ||
| 293 | super::outline::node(&server.durable, sid, texts[1])["kind"]["text"], | ||
| 294 | "Local Sibling 1" | ||
| 295 | ); | ||
| 296 | } | ||
| 297 | } | ||
| 298 | |||
| 299 | #[test] | ||
| 300 | fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() { | ||
| 301 | let (source, sid, outline, p, texts) = fixture(); | ||
| 302 | let insertion = Insertion::paragraph(outline, Some(p[0]), "New sibling", "Remote").unwrap(); | ||
| 303 | let cases = [ | ||
| 304 | ( | ||
| 305 | PreparedEdit::insert(&source, sid, &insertion) | ||
| 306 | .unwrap() | ||
| 307 | .as_bytes() | ||
| 308 | .to_vec(), | ||
| 309 | None, | ||
| 310 | ), | ||
| 311 | ( | ||
| 312 | PreparedEdit::tree(&source, sid, &TreeEdit::delete(p[1], "Remote").unwrap()) | ||
| 313 | .unwrap() | ||
| 314 | .as_bytes() | ||
| 315 | .to_vec(), | ||
| 316 | None, | ||
| 317 | ), | ||
| 318 | ( | ||
| 319 | PreparedEdit::tree( | ||
| 320 | &source, | ||
| 321 | sid, | ||
| 322 | &TreeEdit::move_to(p[1], outline, None, "Remote").unwrap(), | ||
| 323 | ) | ||
| 324 | .unwrap() | ||
| 325 | .as_bytes() | ||
| 326 | .to_vec(), | ||
| 327 | None, | ||
| 328 | ), | ||
| 329 | ( | ||
| 330 | PreparedEdit::tree( | ||
| 331 | &source, | ||
| 332 | sid, | ||
| 333 | &TreeEdit::move_to(p[0], outline, Some(p[2]), "Remote").unwrap(), | ||
| 334 | ) | ||
| 335 | .unwrap() | ||
| 336 | .as_bytes() | ||
| 337 | .to_vec(), | ||
| 338 | Some(ConflictKind::StructureChanged), | ||
| 339 | ), | ||
| 340 | ( | ||
| 341 | PreparedEdit::tree( | ||
| 342 | &source, | ||
| 343 | sid, | ||
| 344 | &TreeEdit::move_to(p[0], p[1], None, "Remote").unwrap(), | ||
| 345 | ) | ||
| 346 | .unwrap() | ||
| 347 | .as_bytes() | ||
| 348 | .to_vec(), | ||
| 349 | Some(ConflictKind::StructureChanged), | ||
| 350 | ), | ||
| 351 | ( | ||
| 352 | PreparedEdit::tree(&source, sid, &TreeEdit::delete(p[0], "Remote").unwrap()) | ||
| 353 | .unwrap() | ||
| 354 | .as_bytes() | ||
| 355 | .to_vec(), | ||
| 356 | Some(ConflictKind::TargetUnavailable), | ||
| 357 | ), | ||
| 358 | ]; | ||
| 359 | for (remote, expected) in cases { | ||
| 360 | let directory = tempfile::tempdir().unwrap(); | ||
| 361 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); | ||
| 362 | let intent = TreeEdit::move_to(p[0], outline, None, "Offline").unwrap(); | ||
| 363 | let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); | ||
| 364 | let mut server = Server::new(&remote); | ||
| 365 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 366 | assert_eq!(result.0, id); | ||
| 367 | if let Some(kind) = expected { | ||
| 368 | assert_eq!(result.1, EditStatus::Conflict(kind)); | ||
| 369 | assert_eq!(server.publications, 0); | ||
| 370 | } else { | ||
| 371 | assert!(matches!(result.1, EditStatus::Published { .. })); | ||
| 372 | assert_eq!(children(&server.durable, sid, outline).last(), Some(&p[0])); | ||
| 373 | assert_eq!( | ||
| 374 | super::outline::node(&server.durable, sid, texts[0])["kind"]["text"], | ||
| 375 | "Original 🦀 é" | ||
| 376 | ); | ||
| 377 | } | ||
| 378 | } | ||
| 379 | } | ||
| 380 | |||
| 381 | #[test] | ||
| 382 | fn moved_destination_and_missing_anchor_retain_conflicts_but_unrelated_text_does_not() { | ||
| 383 | let (source, sid, outline, p, texts) = fixture(); | ||
| 384 | let child = Insertion::paragraph(p[1], None, "Anchor", "Author").unwrap(); | ||
| 385 | let source = PreparedEdit::insert(&source, sid, &child) | ||
| 386 | .unwrap() | ||
| 387 | .as_bytes() | ||
| 388 | .to_vec(); | ||
| 389 | let intent = TreeEdit::move_to(p[0], p[1], Some(child.object()), "Offline").unwrap(); | ||
| 390 | let cases = [ | ||
| 391 | ( | ||
| 392 | PreparedEdit::tree( | ||
| 393 | &source, | ||
| 394 | sid, | ||
| 395 | &TreeEdit::move_to(p[1], p[2], None, "Remote").unwrap(), | ||
| 396 | ) | ||
| 397 | .unwrap() | ||
| 398 | .as_bytes() | ||
| 399 | .to_vec(), | ||
| 400 | ConflictKind::StructureChanged, | ||
| 401 | ), | ||
| 402 | ( | ||
| 403 | PreparedEdit::tree( | ||
| 404 | &source, | ||
| 405 | sid, | ||
| 406 | &TreeEdit::move_to(child.object(), outline, None, "Remote").unwrap(), | ||
| 407 | ) | ||
| 408 | .unwrap() | ||
| 409 | .as_bytes() | ||
| 410 | .to_vec(), | ||
| 411 | ConflictKind::UnsupportedEdit, | ||
| 412 | ), | ||
| 413 | ]; | ||
| 414 | for (remote, kind) in cases { | ||
| 415 | let directory = tempfile::tempdir().unwrap(); | ||
| 416 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); | ||
| 417 | let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); | ||
| 418 | let mut server = Server::new(&remote); | ||
| 419 | assert_eq!( | ||
| 420 | cache.sync_once(&mut server).unwrap(), | ||
| 421 | Some((id, EditStatus::Conflict(kind))) | ||
| 422 | ); | ||
| 423 | assert_eq!(server.publications, 0); | ||
| 424 | } | ||
| 425 | let directory = tempfile::tempdir().unwrap(); | ||
| 426 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); | ||
| 427 | let id = cache | ||
| 428 | .tree(&source, sid, &TreeEdit::delete(p[0], "Offline").unwrap()) | ||
| 429 | .unwrap() | ||
| 430 | .unwrap(); | ||
| 431 | let remote = PreparedEdit::text(&source, sid, texts[1], 0..0, "Remote ").unwrap(); | ||
| 432 | let mut server = Server::new(remote.as_bytes()); | ||
| 433 | assert!( | ||
| 434 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | ||
| 435 | ); | ||
| 436 | assert_eq!( | ||
| 437 | super::outline::node(&server.durable, sid, texts[1])["kind"]["text"], | ||
| 438 | "Remote Sibling 1" | ||
| 439 | ); | ||
| 440 | } | ||
| 441 | |||
| 442 | #[test] | ||
| 443 | fn unknown_tree_attempts_require_revision_evidence_even_when_effect_is_visible() { | ||
| 444 | let (source, sid, outline, p, _) = fixture(); | ||
| 445 | for deletion in [false, true] { | ||
| 446 | for fault in [ | ||
| 447 | Fault::UnknownBefore, | ||
| 448 | Fault::UnknownAfter, | ||
| 449 | Fault::Before, | ||
| 450 | Fault::Committed, | ||
| 451 | ] { | ||
| 452 | let directory = tempfile::tempdir().unwrap(); | ||
| 453 | let path = directory.path().join("cache.sqlite"); | ||
| 454 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 455 | let intent = if deletion { | ||
| 456 | TreeEdit::delete(p[0], "Offline") | ||
| 457 | } else { | ||
| 458 | TreeEdit::move_to(p[0], outline, None, "Offline") | ||
| 459 | } | ||
| 460 | .unwrap(); | ||
| 461 | let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); | ||
| 462 | let mut server = Server::new(&source); | ||
| 463 | server.fault = fault; | ||
| 464 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 465 | let local = cache.snapshot().unwrap(); | ||
| 466 | let before = cache.status(id).unwrap(); | ||
| 467 | drop(cache); | ||
| 468 | let cache = Replica::open(&path).unwrap(); | ||
| 469 | assert_eq!(cache.status(id).unwrap(), before); | ||
| 470 | if matches!(fault, Fault::UnknownBefore) { | ||
| 471 | let independent = if deletion { | ||
| 472 | TreeEdit::delete(p[0], "Other") | ||
| 473 | } else { | ||
| 474 | TreeEdit::move_to(p[0], outline, None, "Other") | ||
| 475 | } | ||
| 476 | .unwrap(); | ||
| 477 | server = Server::new( | ||
| 478 | PreparedEdit::tree(&source, sid, &independent) | ||
| 479 | .unwrap() | ||
| 480 | .as_bytes(), | ||
| 481 | ); | ||
| 482 | assert!( | ||
| 483 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::AwaitingConfirmation { .. })) if n == id) | ||
| 484 | ); | ||
| 485 | assert!( | ||
| 486 | cache | ||
| 487 | .rebase_tree_conflict(id, &local, &server.visible) | ||
| 488 | .is_err() | ||
| 489 | ); | ||
| 490 | assert_eq!(server.publications, 0); | ||
| 491 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 492 | } else if matches!(fault, Fault::Committed) { | ||
| 493 | assert!(matches!( | ||
| 494 | cache.status(id).unwrap(), | ||
| 495 | Some(EditStatus::Published { .. }) | ||
| 496 | )); | ||
| 497 | } else { | ||
| 498 | assert!( | ||
| 499 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | ||
| 500 | ); | ||
| 501 | assert_eq!( | ||
| 502 | server.publications, | ||
| 503 | if matches!(fault, Fault::Before) { 2 } else { 1 } | ||
| 504 | ); | ||
| 505 | let saved = cache.snapshot().unwrap(); | ||
| 506 | assert!(saved[..212] == server.durable[..212]); | ||
| 507 | assert!(saved[252..] == server.durable[252..]); | ||
| 508 | assert_eq!( | ||
| 509 | children(&saved, sid, outline), | ||
| 510 | children(&server.durable, sid, outline) | ||
| 511 | ); | ||
| 512 | } | ||
| 513 | } | ||
| 514 | } | ||
| 515 | } | ||
| 516 | |||
| 517 | #[test] | ||
| 518 | fn independently_satisfied_move_confirms_without_republication() { | ||
| 519 | let (source, sid, outline, p, _) = fixture(); | ||
| 520 | let directory = tempfile::tempdir().unwrap(); | ||
| 521 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); | ||
| 522 | let id = cache | ||
| 523 | .tree( | ||
| 524 | &source, | ||
| 525 | sid, | ||
| 526 | &TreeEdit::move_to(p[0], outline, None, "Offline").unwrap(), | ||
| 527 | ) | ||
| 528 | .unwrap() | ||
| 529 | .unwrap(); | ||
| 530 | let remote = PreparedEdit::tree( | ||
| 531 | &source, | ||
| 532 | sid, | ||
| 533 | &TreeEdit::move_to(p[0], outline, None, "Remote").unwrap(), | ||
| 534 | ) | ||
| 535 | .unwrap(); | ||
| 536 | let mut server = Server::new(remote.as_bytes()); | ||
| 537 | assert!( | ||
| 538 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | ||
| 539 | ); | ||
| 540 | assert_eq!(server.publications, 0); | ||
| 541 | assert_eq!(server.confirmations, 1); | ||
| 542 | } | ||
| 543 | |||
| 544 | #[test] | ||
| 545 | fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay() { | ||
| 546 | let source = | ||
| 547 | include_bytes!("../../../../corpus/outline-edit/tree/before/notebook/synthetic.one"); | ||
| 548 | let store = Store::parse(source).unwrap(); | ||
| 549 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 550 | let document = Document::parse(&index).unwrap(); | ||
| 551 | let (sid, page) = document.pages().unwrap().into_iter().find(|(sid, _)| { | ||
| 552 | let space = &document.spaces[sid]; | ||
| 553 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 554 | matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title: Some(title), .. } if title == "Delete sole cell paragraph") | ||
| 555 | }).unwrap(); | ||
| 556 | let space = &document.spaces[&sid]; | ||
| 557 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 558 | let mut pending = vec![page]; | ||
| 559 | let mut selected = None; | ||
| 560 | let mut other = None; | ||
| 561 | while let Some(id) = pending.pop() { | ||
| 562 | let node = &view.nodes[&id]; | ||
| 563 | pending.extend(&node.children); | ||
| 564 | pending.extend(&node.content); | ||
| 565 | if matches!(node.kind, Kind::Cell { .. }) { | ||
| 566 | let paragraph = node.children[0]; | ||
| 567 | let text = view.nodes[&paragraph].content[0]; | ||
| 568 | if matches!(&view.nodes[&text].kind, Kind::RichText { text, .. } if text.starts_with("Target ")) | ||
| 569 | { | ||
| 570 | selected = Some((id, paragraph)); | ||
| 571 | } else { | ||
| 572 | other = Some((id, text)); | ||
| 573 | } | ||
| 574 | } | ||
| 575 | } | ||
| 576 | let (cell, target) = selected.unwrap(); | ||
| 577 | let (other_cell, other_text) = other.unwrap(); | ||
| 578 | for move_out in [false, true] { | ||
| 579 | for competing_child in [false, true] { | ||
| 580 | let directory = tempfile::tempdir().unwrap(); | ||
| 581 | let path = directory.path().join("cell.sqlite"); | ||
| 582 | let cache = Replica::create(&path, source).unwrap(); | ||
| 583 | let intent = if move_out { | ||
| 584 | TreeEdit::move_to(target, other_cell, None, "Offline") | ||
| 585 | } else { | ||
| 586 | TreeEdit::delete(target, "Offline") | ||
| 587 | } | ||
| 588 | .unwrap(); | ||
| 589 | let id = cache.tree(source, sid, &intent).unwrap().unwrap(); | ||
| 590 | let local = cache.snapshot().unwrap(); | ||
| 591 | let replacement = children(&local, sid, cell); | ||
| 592 | assert_eq!(replacement.len(), 1); | ||
| 593 | assert_ne!(replacement[0], target); | ||
| 594 | let replacement_text: ExGuid = | ||
| 595 | super::outline::node(&local, sid, replacement[0])["content"][0] | ||
| 596 | .as_str() | ||
| 597 | .unwrap() | ||
| 598 | .parse() | ||
| 599 | .unwrap(); | ||
| 600 | let dependent = cache | ||
| 601 | .edit_text(&local, sid, replacement_text, 0..0, "Local replacement") | ||
| 602 | .unwrap() | ||
| 603 | .unwrap(); | ||
| 604 | let local = cache.snapshot().unwrap(); | ||
| 605 | let queue = cache.pending().unwrap(); | ||
| 606 | drop(cache); | ||
| 607 | let cache = Replica::open(&path).unwrap(); | ||
| 608 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 609 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 610 | let remote = if competing_child { | ||
| 611 | PreparedEdit::insert( | ||
| 612 | source, | ||
| 613 | sid, | ||
| 614 | &Insertion::paragraph(cell, None, "Remote sibling", "Remote").unwrap(), | ||
| 615 | ) | ||
| 616 | .unwrap() | ||
| 617 | .as_bytes() | ||
| 618 | .to_vec() | ||
| 619 | } else { | ||
| 620 | PreparedEdit::text(source, sid, other_text, 0..0, "Remote ") | ||
| 621 | .unwrap() | ||
| 622 | .as_bytes() | ||
| 623 | .to_vec() | ||
| 624 | }; | ||
| 625 | let mut server = Server::new(&remote); | ||
| 626 | if competing_child { | ||
| 627 | assert_eq!( | ||
| 628 | cache.sync_once(&mut server).unwrap(), | ||
| 629 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | ||
| 630 | ); | ||
| 631 | assert_eq!(server.publications, 0); | ||
| 632 | assert!(cache.rebase_tree_conflict(id, &local, &remote).is_err()); | ||
| 633 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 634 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 635 | } else { | ||
| 636 | for expected in [id, dependent] { | ||
| 637 | assert!( | ||
| 638 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) | ||
| 639 | ); | ||
| 640 | } | ||
| 641 | assert_eq!(children(&server.durable, sid, cell), replacement); | ||
| 642 | assert_eq!( | ||
| 643 | super::outline::node(&server.durable, sid, replacement_text)["kind"]["text"], | ||
| 644 | "Local replacement" | ||
| 645 | ); | ||
| 646 | assert_eq!( | ||
| 647 | super::outline::node(&server.durable, sid, other_text)["kind"]["text"], | ||
| 648 | "Remote Other cell" | ||
| 649 | ); | ||
| 650 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") { | ||
| 651 | let output = std::path::PathBuf::from(output) | ||
| 652 | .join(if move_out { "cell-move" } else { "cell-delete" }) | ||
| 653 | .join("candidate"); | ||
| 654 | std::fs::create_dir_all(&output).unwrap(); | ||
| 655 | std::fs::write(output.join("synthetic.one"), &server.durable).unwrap(); | ||
| 656 | } | ||
| 657 | } | ||
| 658 | } | ||
| 659 | } | ||
| 660 | } | ||
| 661 | |||
| 662 | #[test] | ||
| 663 | fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_content() { | ||
| 664 | let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); | ||
| 665 | let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one"); | ||
| 666 | let store = Store::parse(source).unwrap(); | ||
| 667 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 668 | let document = Document::parse(&index).unwrap(); | ||
| 669 | let mut server = Server::new(native); | ||
| 670 | let mut counts = [0; 3]; | ||
| 671 | let mut records = Vec::new(); | ||
| 672 | for (sid, page) in document.pages().unwrap() { | ||
| 673 | let space = &document.spaces[&sid]; | ||
| 674 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 675 | let Kind::Metadata { | ||
| 676 | title: Some(name), .. | ||
| 677 | } = &view.nodes[&view.roots[&2]].kind | ||
| 678 | else { | ||
| 679 | continue; | ||
| 680 | }; | ||
| 681 | if name == "Unicode rich text" { | ||
| 682 | continue; | ||
| 683 | } | ||
| 684 | let outlines: Vec<_> = view.nodes[&page] | ||
| 685 | .children | ||
| 686 | .iter() | ||
| 687 | .copied() | ||
| 688 | .filter(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 689 | .collect(); | ||
| 690 | if outlines.len() != 2 { | ||
| 691 | continue; | ||
| 692 | } | ||
| 693 | let outline = outlines[0]; | ||
| 694 | let other = view.nodes[&outlines[1]].children[0]; | ||
| 695 | let dependent_text = view.nodes[&other].content[0]; | ||
| 696 | let mut pending = vec![outline]; | ||
| 697 | let mut paragraphs = std::collections::BTreeMap::new(); | ||
| 698 | while let Some(id) = pending.pop() { | ||
| 699 | let node = &view.nodes[&id]; | ||
| 700 | pending.extend(&node.children); | ||
| 701 | if let Some(text) = node.content.first() | ||
| 702 | && let Kind::RichText { text, .. } = &view.nodes[text].kind | ||
| 703 | { | ||
| 704 | paragraphs.insert(text.as_str(), id); | ||
| 705 | } | ||
| 706 | } | ||
| 707 | let target = *paragraphs | ||
| 708 | .iter() | ||
| 709 | .find(|(text, _)| text.starts_with("Target ")) | ||
| 710 | .unwrap() | ||
| 711 | .1; | ||
| 712 | let (intent, conflict) = match name.as_str() { | ||
| 713 | "Move leaf down" | "Move subtree down" => { | ||
| 714 | (TreeEdit::move_to(target, outline, None, "Offline"), None) | ||
| 715 | } | ||
| 716 | "Move subtree up" => ( | ||
| 717 | TreeEdit::move_to(target, outline, Some(paragraphs["Anchor"]), "Offline"), | ||
| 718 | None, | ||
| 719 | ), | ||
| 720 | "Indent subtree" | "Outdent subtree" => ( | ||
| 721 | TreeEdit::delete(target, "Offline"), | ||
| 722 | Some(ConflictKind::StructureChanged), | ||
| 723 | ), | ||
| 724 | "Collapse subtree" | "Expand subtree" => ( | ||
| 725 | TreeEdit::delete(target, "Offline"), | ||
| 726 | Some(ConflictKind::ContentChanged), | ||
| 727 | ), | ||
| 728 | "Delete leaf" | "Delete subtree" | "Delete only paragraph" => ( | ||
| 729 | TreeEdit::delete(target, "Offline"), | ||
| 730 | Some(ConflictKind::TargetUnavailable), | ||
| 731 | ), | ||
| 732 | "Delete outline" => ( | ||
| 733 | TreeEdit::delete(outline, "Offline"), | ||
| 734 | Some(ConflictKind::TargetUnavailable), | ||
| 735 | ), | ||
| 736 | "Move outline" | "Resize outline" | "Automatic outline size" => ( | ||
| 737 | TreeEdit::delete(outline, "Offline"), | ||
| 738 | Some(ConflictKind::ContentChanged), | ||
| 739 | ), | ||
| 740 | _ => panic!("{name}"), | ||
| 741 | }; | ||
| 742 | let intent = intent.unwrap(); | ||
| 743 | let directory = tempfile::tempdir().unwrap(); | ||
| 744 | let path = directory.path().join("native.sqlite"); | ||
| 745 | let cache = Replica::create(&path, source).unwrap(); | ||
| 746 | let id = cache.tree(source, sid, &intent).unwrap().unwrap(); | ||
| 747 | let dependent = cache | ||
| 748 | .edit_text( | ||
| 749 | &cache.snapshot().unwrap(), | ||
| 750 | sid, | ||
| 751 | dependent_text, | ||
| 752 | 0..0, | ||
| 753 | "Offline ", | ||
| 754 | ) | ||
| 755 | .unwrap() | ||
| 756 | .unwrap(); | ||
| 757 | let local = cache.snapshot().unwrap(); | ||
| 758 | let queue = cache.pending().unwrap(); | ||
| 759 | let old_publications = server.publications; | ||
| 760 | let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 761 | assert_eq!(actual, id); | ||
| 762 | if let Some(kind) = conflict { | ||
| 763 | assert_eq!(status, EditStatus::Conflict(kind), "{name}"); | ||
| 764 | assert_eq!(server.publications, old_publications); | ||
| 765 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 766 | assert!(cache.snapshot().unwrap() == local); | ||
| 767 | drop(cache); | ||
| 768 | let cache = Replica::open(&path).unwrap(); | ||
| 769 | assert_eq!(cache.status(id).unwrap(), Some(status)); | ||
| 770 | if kind == ConflictKind::TargetUnavailable { | ||
| 771 | counts[2] += 1; | ||
| 772 | assert!( | ||
| 773 | cache | ||
| 774 | .rebase_tree_conflict(id, &local, &server.visible) | ||
| 775 | .is_err() | ||
| 776 | ); | ||
| 777 | records.push(serde_json::json!({"page": name, "space": sid, "result": "retained"})); | ||
| 778 | continue; | ||
| 779 | } | ||
| 780 | counts[1] += 1; | ||
| 781 | cache | ||
| 782 | .rebase_tree_conflict(id, &local, &server.visible) | ||
| 783 | .unwrap(); | ||
| 784 | assert_eq!(cache.pending().unwrap()[1], queue[1]); | ||
| 785 | for expected in [id, dependent] { | ||
| 786 | assert!( | ||
| 787 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) | ||
| 788 | ); | ||
| 789 | } | ||
| 790 | } else { | ||
| 791 | counts[0] += 1; | ||
| 792 | assert!(matches!(status, EditStatus::Published { .. }), "{name}"); | ||
| 793 | assert_eq!(server.publications, old_publications); | ||
| 794 | assert!( | ||
| 795 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == dependent) | ||
| 796 | ); | ||
| 797 | } | ||
| 798 | assert_eq!( | ||
| 799 | super::outline::node(&server.durable, sid, dependent_text)["kind"]["text"], | ||
| 800 | format!( | ||
| 801 | "Offline {}", | ||
| 802 | match &view.nodes[&dependent_text].kind { | ||
| 803 | Kind::RichText { text, .. } => text, | ||
| 804 | _ => panic!(), | ||
| 805 | } | ||
| 806 | ) | ||
| 807 | ); | ||
| 808 | records.push(serde_json::json!({"page": name, "space": sid, "result": if conflict.is_some() { "reviewed" } else { "converged" }})); | ||
| 809 | } | ||
| 810 | assert_eq!(counts, [3, 7, 4]); | ||
| 811 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") { | ||
| 812 | let output = std::path::PathBuf::from(output); | ||
| 813 | std::fs::create_dir_all(output.join("candidate")).unwrap(); | ||
| 814 | std::fs::write(output.join("candidate/synthetic.one"), &server.durable).unwrap(); | ||
| 815 | std::fs::write( | ||
| 816 | output.join("manifest.json"), | ||
| 817 | serde_json::to_vec_pretty(&records).unwrap(), | ||
| 818 | ) | ||
| 819 | .unwrap(); | ||
| 820 | } | ||
| 821 | } | ||
crates/onestore-diagnostic/Cargo.toml deleted-11| ... | @@ -1,11 +0,0 @@ | ||
| 1 | [package] | ||
| 2 | name = "onestore-diagnostic" | ||
| 3 | version = "0.1.0" | ||
| 4 | edition = "2024" | ||
| 5 | publish = false | ||
| 6 | |||
| 7 | [dependencies] | ||
| 8 | onestore = { path = "../onestore" } | ||
| 9 | onestore-notebook = { path = "../onestore-notebook" } | ||
| 10 | serde = { version = "1.0.229", features = ["derive"] } | ||
| 11 | serde_json = "1.0.151" | ||
crates/onestore-diagnostic/src/main.rs deleted-126| ... | @@ -1,126 +0,0 @@ | ||
| 1 | use onestore::{ExGuid, Insertion, PreparedEdit, TextAttribute}; | ||
| 2 | use serde::Deserialize; | ||
| 3 | use serde_json::{Value, json}; | ||
| 4 | use std::{ | ||
| 5 | env, fs, | ||
| 6 | io::{self, Write}, | ||
| 7 | }; | ||
| 8 | |||
| 9 | #[derive(Deserialize)] | ||
| 10 | #[serde(deny_unknown_fields)] | ||
| 11 | struct Edit { | ||
| 12 | space: ExGuid, | ||
| 13 | object: ExGuid, | ||
| 14 | action: Action, | ||
| 15 | } | ||
| 16 | |||
| 17 | #[derive(Deserialize)] | ||
| 18 | #[serde(tag = "type", deny_unknown_fields)] | ||
| 19 | enum Action { | ||
| 20 | Text { | ||
| 21 | start: u32, | ||
| 22 | end: u32, | ||
| 23 | replacement: String, | ||
| 24 | }, | ||
| 25 | Format { | ||
| 26 | start: u32, | ||
| 27 | end: u32, | ||
| 28 | attributes: Vec<TextAttribute>, | ||
| 29 | }, | ||
| 30 | Paragraph { | ||
| 31 | before: Option<ExGuid>, | ||
| 32 | text: String, | ||
| 33 | author: String, | ||
| 34 | }, | ||
| 35 | Outline { | ||
| 36 | x: f32, | ||
| 37 | y: f32, | ||
| 38 | text: String, | ||
| 39 | author: String, | ||
| 40 | }, | ||
| 41 | } | ||
| 42 | |||
| 43 | fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>> { | ||
| 44 | if let [mode, root] = args | ||
| 45 | && mode == "catalog" | ||
| 46 | { | ||
| 47 | let catalog = onestore_notebook::discover( | ||
| 48 | &mut onestore_notebook::Local::open(root)?, | ||
| 49 | onestore_notebook::Limits { | ||
| 50 | entries: 100_000, | ||
| 51 | bytes_per_file: 256 * 1024 * 1024, | ||
| 52 | depth: 64, | ||
| 53 | }, | ||
| 54 | )?; | ||
| 55 | return Ok(json!({"ok": true, "catalog": catalog})); | ||
| 56 | } | ||
| 57 | if args.len() != 3 | ||
| 58 | || !["snapshot", "check", "commit"] | ||
| 59 | .iter() | ||
| 60 | .any(|mode| args[0] == *mode) | ||
| 61 | { | ||
| 62 | return Err("Usage: onestore-diagnostic catalog ROOT | snapshot FILE NEW_SNAPSHOT | check SNAPSHOT - | commit FILE SNAPSHOT; edits arrive as JSON on stdin".into()); | ||
| 63 | } | ||
| 64 | if args[0] == "snapshot" { | ||
| 65 | let bytes = onestore::read_file(&args[1])?; | ||
| 66 | let mut file = fs::OpenOptions::new() | ||
| 67 | .write(true) | ||
| 68 | .create_new(true) | ||
| 69 | .open(&args[2])?; | ||
| 70 | file.write_all(&bytes)?; | ||
| 71 | file.sync_all()?; | ||
| 72 | return Ok(json!({"ok": true, "bytes": bytes.len()})); | ||
| 73 | } | ||
| 74 | let check = args[0] == "check"; | ||
| 75 | if check && args[2] != "-" { | ||
| 76 | return Err("The check command requires '-' as its final argument".into()); | ||
| 77 | } | ||
| 78 | let bytes = fs::read(if check { &args[1] } else { &args[2] })?; | ||
| 79 | let edit: Edit = serde_json::from_reader(io::stdin().lock())?; | ||
| 80 | let sid = edit.space; | ||
| 81 | let oid = edit.object; | ||
| 82 | let prepared = match edit.action { | ||
| 83 | Action::Text { | ||
| 84 | start, | ||
| 85 | end, | ||
| 86 | replacement, | ||
| 87 | } => PreparedEdit::text(&bytes, sid, oid, start..end, &replacement)?, | ||
| 88 | Action::Format { | ||
| 89 | start, | ||
| 90 | end, | ||
| 91 | attributes, | ||
| 92 | } => PreparedEdit::format(&bytes, sid, oid, start..end, &attributes)?, | ||
| 93 | Action::Paragraph { | ||
| 94 | before, | ||
| 95 | text, | ||
| 96 | author, | ||
| 97 | } => PreparedEdit::insert( | ||
| 98 | &bytes, | ||
| 99 | sid, | ||
| 100 | &Insertion::paragraph(oid, before, &text, &author)?, | ||
| 101 | )?, | ||
| 102 | Action::Outline { x, y, text, author } => { | ||
| 103 | PreparedEdit::insert(&bytes, sid, &Insertion::outline(oid, x, y, &text, &author)?)? | ||
| 104 | } | ||
| 105 | }; | ||
| 106 | if check { | ||
| 107 | return Ok(json!({"ok": true})); | ||
| 108 | } | ||
| 109 | Ok(match prepared.commit_file(&args[1]) { | ||
| 110 | Ok(()) => json!({"ok": true, "state": "Committed"}), | ||
| 111 | Err(error) => json!({"ok": false, "state": format!("{:?}", error.state), | ||
| 112 | "kind": format!("{:?}", error.error.kind()), "error": error.error.to_string()}), | ||
| 113 | }) | ||
| 114 | } | ||
| 115 | |||
| 116 | fn main() { | ||
| 117 | let args: Vec<_> = env::args_os().skip(1).collect(); | ||
| 118 | let result = run(&args).unwrap_or_else(|error| { | ||
| 119 | let mut result = json!({"ok": false, "kind": "Input", "error": error.to_string()}); | ||
| 120 | if args.first().is_some_and(|mode| mode == "commit") { | ||
| 121 | result["state"] = json!("NotCommitted"); | ||
| 122 | } | ||
| 123 | result | ||
| 124 | }); | ||
| 125 | println!("{result}"); | ||
| 126 | } | ||
crates/onestore-notebook/Cargo.toml deleted-20| ... | @@ -1,20 +0,0 @@ | ||
| 1 | [package] | ||
| 2 | name = "onestore-notebook" | ||
| 3 | version = "0.1.0" | ||
| 4 | edition = "2024" | ||
| 5 | publish = false | ||
| 6 | |||
| 7 | [features] | ||
| 8 | smb = ["dep:onestore-smb"] | ||
| 9 | protected = ["onestore/protected", "dep:zeroize"] | ||
| 10 | |||
| 11 | [dependencies] | ||
| 12 | onestore = { path = "../onestore" } | ||
| 13 | onestore-smb = { path = "../onestore-smb", optional = true } | ||
| 14 | serde = { version = "1", features = ["derive"] } | ||
| 15 | thiserror = "2" | ||
| 16 | zeroize = { version = "1.9.0", optional = true } | ||
| 17 | |||
| 18 | [dev-dependencies] | ||
| 19 | serde_json = "1" | ||
| 20 | tempfile = "3" | ||
crates/onestore-notebook/README.md deleted-25| ... | @@ -1,25 +0,0 @@ | ||
| 1 | # onestore-notebook | ||
| 2 | |||
| 3 | Read-only notebook discovery over a caller-supplied root. This experimental | ||
| 4 | sibling crate keeps directory traversal out of the single-file storage parser. | ||
| 5 | |||
| 6 | Discovery returns ordered sections and nested groups with file identities and | ||
| 7 | share-relative paths. Section display-name overrides remain distinct from file | ||
| 8 | names. TOC references whose identities are absent from the directory remain | ||
| 9 | inspectable; a cached filename never substitutes for an identity match. | ||
| 10 | Reserved `_onefiles` directories are excluded from section-group traversal. | ||
| 11 | Encrypted sections and valid storage with an unreadable document graph retain | ||
| 12 | their identity as `Locked` or `Unreadable`, without being presented as empty pages. | ||
| 13 | Malformed storage, failed reads and ambiguous identities reject the discovery. | ||
| 14 | |||
| 15 | Each file read must be a consistent, bounded snapshot. The result is an | ||
| 16 | observation across multiple files, not an atomic notebook transaction or | ||
| 17 | authorization to publish an edit. Refresh rejects observed topology changes and | ||
| 18 | duplicate physical files with the same logical identity. Retain the last accepted | ||
| 19 | catalog if discovery fails; a connection failure does not mean files were deleted. | ||
| 20 | |||
| 21 | `read_external_asset` resolves a validated UUID `.onebin` filename beneath the | ||
| 22 | selected section's sibling `_onefiles` folder and returns exact bounded bytes. | ||
| 23 | Missing files, permissions and size failures retain their I/O error kinds; an | ||
| 24 | empty payload is a successful empty buffer. Embedded payloads remain available | ||
| 25 | directly from the core document model. | ||
crates/onestore-notebook/examples/discover.rs deleted-40| ... | @@ -1,40 +0,0 @@ | ||
| 1 | use onestore_notebook::{Limits, Local, discover}; | ||
| 2 | |||
| 3 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 4 | let args: Vec<_> = std::env::args_os().skip(1).collect(); | ||
| 5 | let limits = Limits { | ||
| 6 | entries: 100_000, | ||
| 7 | bytes_per_file: 256 * 1024 * 1024, | ||
| 8 | depth: 64, | ||
| 9 | }; | ||
| 10 | let catalog = match args.as_slice() { | ||
| 11 | [root] => discover(&mut Local::open(root)?, limits)?, | ||
| 12 | #[cfg(feature = "smb")] | ||
| 13 | [flag, address, share, root] if flag == "--smb" => { | ||
| 14 | use onestore_smb::{Client, Credentials}; | ||
| 15 | let username = std::env::var("ONESTORE_SMB_USERNAME").unwrap_or_default(); | ||
| 16 | let password = std::env::var("ONESTORE_SMB_PASSWORD").unwrap_or_default(); | ||
| 17 | let domain = std::env::var("ONESTORE_SMB_DOMAIN").unwrap_or_default(); | ||
| 18 | let client = Client::connect( | ||
| 19 | address.to_str().ok_or("Use a UTF-8 server address")?, | ||
| 20 | share.to_str().ok_or("Use a UTF-8 share name")?, | ||
| 21 | Credentials { | ||
| 22 | username: &username, | ||
| 23 | password: &password, | ||
| 24 | domain: &domain, | ||
| 25 | }, | ||
| 26 | std::time::Duration::from_secs(5), | ||
| 27 | )?; | ||
| 28 | discover( | ||
| 29 | &mut onestore_notebook::Smb::new( | ||
| 30 | &client, | ||
| 31 | root.to_str().ok_or("Use a UTF-8 notebook path")?, | ||
| 32 | )?, | ||
| 33 | limits, | ||
| 34 | )? | ||
| 35 | } | ||
| 36 | _ => return Err("Provide ROOT, or --smb ADDRESS SHARE ROOT with the smb feature".into()), | ||
| 37 | }; | ||
| 38 | serde_json::to_writer_pretty(std::io::stdout().lock(), &catalog)?; | ||
| 39 | Ok(()) | ||
| 40 | } | ||
crates/onestore-notebook/examples/document.rs deleted-170| ... | @@ -1,170 +0,0 @@ | ||
| 1 | use onestore::{ | ||
| 2 | FileDataReference, RevisionIndex, Store, | ||
| 3 | document::{Document, Kind}, | ||
| 4 | }; | ||
| 5 | use std::{ | ||
| 6 | borrow::Cow, | ||
| 7 | collections::BTreeSet, | ||
| 8 | env, fs, | ||
| 9 | io::{self, BufWriter, Write}, | ||
| 10 | path::{Path, PathBuf}, | ||
| 11 | }; | ||
| 12 | |||
| 13 | fn write_json( | ||
| 14 | path: impl AsRef<Path>, | ||
| 15 | value: &impl serde::Serialize, | ||
| 16 | ) -> Result<(), Box<dyn std::error::Error>> { | ||
| 17 | let mut output = BufWriter::new(fs::File::create(path)?); | ||
| 18 | serde_json::to_writer(&mut output, value)?; | ||
| 19 | output.flush()?; | ||
| 20 | Ok(()) | ||
| 21 | } | ||
| 22 | |||
| 23 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 24 | let mut args = env::args_os().skip(1); | ||
| 25 | let path = args.next().ok_or("Provide a .one or .onetoc2 file.")?; | ||
| 26 | let next = args.next(); | ||
| 27 | let (destination, option) = if next.as_deref() == Some(std::ffi::OsStr::new("--password-file")) | ||
| 28 | { | ||
| 29 | (None, next) | ||
| 30 | } else { | ||
| 31 | (next.map(PathBuf::from), args.next()) | ||
| 32 | }; | ||
| 33 | let password_file = match (option, args.next(), args.next()) { | ||
| 34 | (None, None, None) => None, | ||
| 35 | (Some(flag), Some(path), None) if flag == "--password-file" => Some(PathBuf::from(path)), | ||
| 36 | _ => return Err("Provide a source file, an optional new export directory, and optionally --password-file PATH.".into()), | ||
| 37 | }; | ||
| 38 | let source_path = PathBuf::from(path); | ||
| 39 | let bytes = onestore::read_file(&source_path)?; | ||
| 40 | let store = Store::parse(&bytes)?; | ||
| 41 | let index = RevisionIndex::parse(&store)?; | ||
| 42 | #[cfg(feature = "protected")] | ||
| 43 | let unlocked = if let Some(path) = &password_file { | ||
| 44 | use std::io::Read; | ||
| 45 | let mut password = zeroize::Zeroizing::new(String::new()); | ||
| 46 | fs::File::open(path)? | ||
| 47 | .take(65537) | ||
| 48 | .read_to_string(&mut password)?; | ||
| 49 | if password.len() > 65536 { | ||
| 50 | return Err("The password file exceeds 64 KiB.".into()); | ||
| 51 | } | ||
| 52 | Some(onestore::protected::UnlockedSection::open( | ||
| 53 | &index, | ||
| 54 | &password, | ||
| 55 | Default::default(), | ||
| 56 | )?) | ||
| 57 | } else { | ||
| 58 | None | ||
| 59 | }; | ||
| 60 | #[cfg(not(feature = "protected"))] | ||
| 61 | if password_file.is_some() { | ||
| 62 | return Err( | ||
| 63 | "Build this exporter with the protected feature to open a password-protected section." | ||
| 64 | .into(), | ||
| 65 | ); | ||
| 66 | } | ||
| 67 | #[cfg(feature = "protected")] | ||
| 68 | let document = match &unlocked { | ||
| 69 | Some(section) => section.document()?, | ||
| 70 | None => Document::parse(&index)?, | ||
| 71 | }; | ||
| 72 | #[cfg(not(feature = "protected"))] | ||
| 73 | let document = Document::parse(&index)?; | ||
| 74 | if let Some(destination) = destination { | ||
| 75 | #[cfg(unix)] | ||
| 76 | { | ||
| 77 | use std::os::unix::fs::DirBuilderExt; | ||
| 78 | fs::DirBuilder::new() | ||
| 79 | .mode(if password_file.is_some() { | ||
| 80 | 0o700 | ||
| 81 | } else { | ||
| 82 | 0o777 | ||
| 83 | }) | ||
| 84 | .create(&destination)?; | ||
| 85 | } | ||
| 86 | #[cfg(not(unix))] | ||
| 87 | fs::create_dir(&destination)?; | ||
| 88 | fs::create_dir(destination.join("assets"))?; | ||
| 89 | let parent = source_path | ||
| 90 | .parent() | ||
| 91 | .filter(|parent| !parent.as_os_str().is_empty()) | ||
| 92 | .unwrap_or_else(|| Path::new(".")); | ||
| 93 | let mut source = onestore_notebook::Local::open(parent)?; | ||
| 94 | let section = source_path | ||
| 95 | .file_name() | ||
| 96 | .and_then(|name| name.to_str()) | ||
| 97 | .ok_or("Use a UTF-8 section filename")?; | ||
| 98 | let mut assets = Vec::new(); | ||
| 99 | let mut seen = BTreeSet::new(); | ||
| 100 | for node in document | ||
| 101 | .spaces | ||
| 102 | .values() | ||
| 103 | .flat_map(|s| s.revisions.values()) | ||
| 104 | .flat_map(|r| r.nodes.values()) | ||
| 105 | { | ||
| 106 | if let Kind::File { | ||
| 107 | reference, payload, .. | ||
| 108 | } = &node.kind | ||
| 109 | && seen.insert(serde_json::to_string(reference)?) | ||
| 110 | { | ||
| 111 | let data = match reference { | ||
| 112 | FileDataReference::Internal(_) => { | ||
| 113 | Cow::Borrowed(payload.ok_or("Missing embedded file data")?) | ||
| 114 | } | ||
| 115 | FileDataReference::External(filename) => { | ||
| 116 | if password_file.is_some() { | ||
| 117 | assets.push(serde_json::json!({"reference":reference,"path":null,"error":{"kind":"Unsupported","message":"Protected external payload export is not supported"}})); | ||
| 118 | continue; | ||
| 119 | } | ||
| 120 | match onestore_notebook::read_external_asset( | ||
| 121 | &mut source, | ||
| 122 | section, | ||
| 123 | filename, | ||
| 124 | 256 * 1024 * 1024, | ||
| 125 | ) { | ||
| 126 | Ok(bytes) => Cow::Owned(bytes), | ||
| 127 | Err(error) => { | ||
| 128 | let kind = match &error { | ||
| 129 | onestore_notebook::Error::Io { error, .. } => { | ||
| 130 | format!("{:?}", error.kind()) | ||
| 131 | } | ||
| 132 | _ => "InvalidData".into(), | ||
| 133 | }; | ||
| 134 | assets.push(serde_json::json!({"reference":reference,"path":null,"error":{"kind":kind,"message":error.to_string()}})); | ||
| 135 | continue; | ||
| 136 | } | ||
| 137 | } | ||
| 138 | } | ||
| 139 | FileDataReference::Invalid => { | ||
| 140 | assets.push(serde_json::json!({"reference":reference,"path":null,"error":{"kind":"InvalidData","message":"The document marks this payload as unavailable"}})); | ||
| 141 | continue; | ||
| 142 | } | ||
| 143 | }; | ||
| 144 | let path = format!("assets/{}.bin", assets.len()); | ||
| 145 | fs::write(destination.join(&path), data)?; | ||
| 146 | assets.push(serde_json::json!({"reference":reference,"path":path})); | ||
| 147 | } | ||
| 148 | } | ||
| 149 | write_json(destination.join("assets.json"), &assets)?; | ||
| 150 | let mut text = std::collections::BTreeMap::new(); | ||
| 151 | for (sid, space) in &document.spaces { | ||
| 152 | let mut revisions = std::collections::BTreeMap::new(); | ||
| 153 | for (rid, revision) in &space.revisions { | ||
| 154 | let mut objects = std::collections::BTreeMap::new(); | ||
| 155 | for (oid, node) in &revision.nodes { | ||
| 156 | if matches!(node.kind, Kind::RichText { .. }) { | ||
| 157 | objects.insert(*oid, revision.text_runs(*oid)?); | ||
| 158 | } | ||
| 159 | } | ||
| 160 | revisions.insert(*rid, objects); | ||
| 161 | } | ||
| 162 | text.insert(*sid, revisions); | ||
| 163 | } | ||
| 164 | write_json(destination.join("text.json"), &text)?; | ||
| 165 | write_json(destination.join("document.json"), &document)?; | ||
| 166 | } else { | ||
| 167 | serde_json::to_writer(io::stdout().lock(), &document)?; | ||
| 168 | } | ||
| 169 | Ok(()) | ||
| 170 | } | ||
crates/onestore-notebook/examples/externalize_fixture.rs deleted-101| ... | @@ -1,101 +0,0 @@ | ||
| 1 | //! Builds a new external-payload test fixture from a native capture with reserved fragment space. | ||
| 2 | use onestore::{FileDataReference, RevisionIndex, Store}; | ||
| 3 | use std::{fs, path::PathBuf}; | ||
| 4 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 5 | let args: Vec<_> = std::env::args_os().skip(1).collect(); | ||
| 6 | if args.len() != 2 { | ||
| 7 | return Err("Provide a native source section and a new fixture directory".into()); | ||
| 8 | } | ||
| 9 | let source = fs::read(&args[0])?; | ||
| 10 | let destination = PathBuf::from(&args[1]); | ||
| 11 | fs::create_dir(&destination)?; | ||
| 12 | fs::create_dir(destination.join("synthetic_onefiles"))?; | ||
| 13 | let store = Store::parse(&source)?; | ||
| 14 | assert!(store.checksum_mismatches.is_empty()); | ||
| 15 | RevisionIndex::parse(&store)?.validate_current()?; | ||
| 16 | let mut output = source.clone(); | ||
| 17 | let mut changed = 0; | ||
| 18 | for list in store.lists.values() { | ||
| 19 | for chunk in &list.fragments { | ||
| 20 | let start = chunk.offset as usize; | ||
| 21 | let end = start + chunk.length as usize - 20; | ||
| 22 | let mut body = Vec::new(); | ||
| 23 | let mut touched = false; | ||
| 24 | for node in list | ||
| 25 | .nodes | ||
| 26 | .iter() | ||
| 27 | .filter(|node| node.offset >= start + 16 && node.offset < end) | ||
| 28 | { | ||
| 29 | let raw = u32::from_le_bytes(source[node.offset..node.offset + 4].try_into()?); | ||
| 30 | let size = ((raw >> 10) & 0x1fff) as usize; | ||
| 31 | let mut encoded = source[node.offset..node.offset + size].to_vec(); | ||
| 32 | if matches!(node.id, 0x72 | 0x73) { | ||
| 33 | let offset = 4 + 4 + 4 + if node.id == 0x72 { 1 } else { 4 }; | ||
| 34 | let count = | ||
| 35 | u32::from_le_bytes(encoded[offset..offset + 4].try_into()?) as usize; | ||
| 36 | let text = String::from_utf16( | ||
| 37 | &encoded[offset + 4..offset + 4 + count * 2] | ||
| 38 | .chunks_exact(2) | ||
| 39 | .map(|v| u16::from_le_bytes(v.try_into().unwrap())) | ||
| 40 | .collect::<Vec<_>>(), | ||
| 41 | )?; | ||
| 42 | if let FileDataReference::Internal(guid) = text.parse()? { | ||
| 43 | let filename = format!( | ||
| 44 | "{}.onebin", | ||
| 45 | text.strip_prefix("<ifndf>{") | ||
| 46 | .unwrap() | ||
| 47 | .strip_suffix('}') | ||
| 48 | .unwrap() | ||
| 49 | ); | ||
| 50 | let payload = store.file_data(guid)?; | ||
| 51 | let path = destination.join("synthetic_onefiles").join(&filename); | ||
| 52 | if path.exists() { | ||
| 53 | assert_eq!(fs::read(&path)?, payload); | ||
| 54 | } else { | ||
| 55 | fs::write(path, payload)?; | ||
| 56 | } | ||
| 57 | let reference = format!("<file>{filename}"); | ||
| 58 | let data: Vec<_> = reference | ||
| 59 | .encode_utf16() | ||
| 60 | .flat_map(u16::to_le_bytes) | ||
| 61 | .collect(); | ||
| 62 | encoded.splice(offset + 4..offset + 4 + count * 2, data.iter().copied()); | ||
| 63 | encoded[offset..offset + 4] | ||
| 64 | .copy_from_slice(&(data.len() as u32 / 2).to_le_bytes()); | ||
| 65 | assert!(encoded.len() <= 0x1fff); | ||
| 66 | let header = (raw & !(0x1fff << 10)) | ((encoded.len() as u32) << 10); | ||
| 67 | encoded[..4].copy_from_slice(&header.to_le_bytes()); | ||
| 68 | changed += 1; | ||
| 69 | touched = true; | ||
| 70 | } | ||
| 71 | } | ||
| 72 | body.extend(encoded); | ||
| 73 | } | ||
| 74 | if touched { | ||
| 75 | assert!( | ||
| 76 | body.len() + 4 <= end - start - 16, | ||
| 77 | "Native fragment has insufficient reserved space" | ||
| 78 | ); | ||
| 79 | body.extend_from_slice(&(0xff_u32 | (4 << 10)).to_le_bytes()); | ||
| 80 | output[start + 16..end].fill(0); | ||
| 81 | output[start + 16..start + 16 + body.len()].copy_from_slice(&body); | ||
| 82 | } | ||
| 83 | } | ||
| 84 | } | ||
| 85 | assert!(changed > 0); | ||
| 86 | let converted = Store::parse(&output)?; | ||
| 87 | assert!(converted.checksum_mismatches.is_empty()); | ||
| 88 | RevisionIndex::parse(&converted)?.validate_current()?; | ||
| 89 | fs::write(destination.join("synthetic.one"), &output)?; | ||
| 90 | fs::write( | ||
| 91 | destination.join("Open Notebook.onetoc2"), | ||
| 92 | onestore::create_table_of_contents( | ||
| 93 | "Open Notebook.onetoc2", | ||
| 94 | &[("synthetic.one", converted.header.file_id)], | ||
| 95 | )?, | ||
| 96 | )?; | ||
| 97 | println!( | ||
| 98 | "Converted {changed} file-data declarations without changing payload identities or bytes" | ||
| 99 | ); | ||
| 100 | Ok(()) | ||
| 101 | } | ||
crates/onestore-notebook/src/lib.rs deleted-413| ... | @@ -1,413 +0,0 @@ | ||
| 1 | #![forbid(unsafe_code)] | ||
| 2 | #![doc = include_str!("../README.md")] | ||
| 3 | |||
| 4 | use onestore::{ | ||
| 5 | FileType, RevisionIndex, Store, | ||
| 6 | document::{Document, Kind}, | ||
| 7 | }; | ||
| 8 | use serde::Serialize; | ||
| 9 | use std::{ | ||
| 10 | collections::{BTreeMap, BTreeSet}, | ||
| 11 | io, | ||
| 12 | }; | ||
| 13 | |||
| 14 | mod source; | ||
| 15 | pub use source::Local; | ||
| 16 | #[cfg(feature = "smb")] | ||
| 17 | pub use source::Smb; | ||
| 18 | |||
| 19 | #[derive(Debug, Serialize)] | ||
| 20 | pub struct Section { | ||
| 21 | pub path: String, | ||
| 22 | /// Header.guidFile, also used by FileIdentityGuid in a parent TOC. | ||
| 23 | pub file_id: [u8; 16], | ||
| 24 | pub state: SectionState, | ||
| 25 | } | ||
| 26 | |||
| 27 | #[derive(Debug, Serialize)] | ||
| 28 | pub enum SectionState { | ||
| 29 | Readable { | ||
| 30 | /// An explicit SectionDisplayName; otherwise use the current filename without its extension. | ||
| 31 | name: Option<String>, | ||
| 32 | }, | ||
| 33 | Locked, | ||
| 34 | Unreadable(onestore::Error), | ||
| 35 | } | ||
| 36 | |||
| 37 | #[derive(Debug, Serialize)] | ||
| 38 | pub struct Folder { | ||
| 39 | pub path: String, | ||
| 40 | pub toc: Option<Toc>, | ||
| 41 | pub sections: Vec<Section>, | ||
| 42 | pub groups: Vec<Folder>, | ||
| 43 | } | ||
| 44 | |||
| 45 | #[derive(Debug, Serialize)] | ||
| 46 | pub struct Toc { | ||
| 47 | pub filename: String, | ||
| 48 | pub file_id: [u8; 16], | ||
| 49 | /// Stale or unavailable TOC references; these are not inferred active sections. | ||
| 50 | pub unresolved: Vec<TocReference>, | ||
| 51 | } | ||
| 52 | |||
| 53 | #[derive(Debug, Serialize)] | ||
| 54 | pub struct TocReference { | ||
| 55 | /// Native TOCs can retain an identity after removing its cached filename. | ||
| 56 | pub filename: Option<String>, | ||
| 57 | pub file: [u8; 16], | ||
| 58 | pub order: u32, | ||
| 59 | } | ||
| 60 | |||
| 61 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | ||
| 62 | pub enum EntryKind { | ||
| 63 | File, | ||
| 64 | Directory, | ||
| 65 | Other, | ||
| 66 | } | ||
| 67 | |||
| 68 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] | ||
| 69 | pub struct Entry { | ||
| 70 | pub name: String, | ||
| 71 | pub kind: EntryKind, | ||
| 72 | } | ||
| 73 | |||
| 74 | /// Rooted file access. Paths are relative, UTF-8, and use `/` between components. | ||
| 75 | pub trait Source { | ||
| 76 | /// Return the complete immediate directory or an error, never a truncated success. | ||
| 77 | fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>>; | ||
| 78 | /// Return a consistent file snapshot, rejecting images larger than the byte limit. | ||
| 79 | fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>>; | ||
| 80 | /// Reads an external payload with the same completeness and size guarantees. | ||
| 81 | fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 82 | self.read(path, limit) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | /// Reads an external file-data reference from the section's sibling `_onefiles` folder. | ||
| 87 | /// `NotFound` in `Error::Io` is distinct from a successfully read zero-byte payload. | ||
| 88 | pub fn read_external_asset( | ||
| 89 | source: &mut impl Source, | ||
| 90 | section: &str, | ||
| 91 | filename: &str, | ||
| 92 | limit: usize, | ||
| 93 | ) -> Result<Vec<u8>, Error> { | ||
| 94 | let (stem, extension) = section.rsplit_once('.').ok_or_else(|| Error::Entry { | ||
| 95 | path: section.into(), | ||
| 96 | })?; | ||
| 97 | if !extension.eq_ignore_ascii_case("one") | ||
| 98 | || !section.split('/').all(component) | ||
| 99 | || stem.ends_with('/') | ||
| 100 | || stem.is_empty() | ||
| 101 | { | ||
| 102 | return Err(Error::Entry { | ||
| 103 | path: section.into(), | ||
| 104 | }); | ||
| 105 | } | ||
| 106 | format!("<file>{filename}") | ||
| 107 | .parse::<onestore::FileDataReference>() | ||
| 108 | .map_err(|_| Error::Entry { | ||
| 109 | path: filename.into(), | ||
| 110 | })?; | ||
| 111 | let path = format!("{stem}_onefiles/{filename}"); | ||
| 112 | let bytes = source.read_asset(&path, limit).map_err(|error| Error::Io { | ||
| 113 | path: path.clone(), | ||
| 114 | error, | ||
| 115 | })?; | ||
| 116 | if bytes.len() > limit { | ||
| 117 | return Err(Error::Limit { path }); | ||
| 118 | } | ||
| 119 | Ok(bytes) | ||
| 120 | } | ||
| 121 | |||
| 122 | pub struct Limits { | ||
| 123 | pub entries: usize, | ||
| 124 | pub bytes_per_file: usize, | ||
| 125 | pub depth: usize, | ||
| 126 | } | ||
| 127 | |||
| 128 | #[derive(Debug, thiserror::Error)] | ||
| 129 | pub enum Error { | ||
| 130 | #[error("Cannot read {path}: {error}")] | ||
| 131 | Io { | ||
| 132 | path: String, | ||
| 133 | #[source] | ||
| 134 | error: io::Error, | ||
| 135 | }, | ||
| 136 | #[error("Invalid notebook file {path}: {error}")] | ||
| 137 | Document { | ||
| 138 | path: String, | ||
| 139 | #[source] | ||
| 140 | error: onestore::Error, | ||
| 141 | }, | ||
| 142 | #[error("Discovery limit exceeded at {path}")] | ||
| 143 | Limit { path: String }, | ||
| 144 | #[error("Directory changed during discovery: {path}")] | ||
| 145 | Changed { path: String }, | ||
| 146 | #[error("Invalid or unsupported directory entry: {path}")] | ||
| 147 | Entry { path: String }, | ||
| 148 | #[error("File identity occurs at both {first} and {second}")] | ||
| 149 | DuplicateIdentity { | ||
| 150 | file: [u8; 16], | ||
| 151 | first: String, | ||
| 152 | second: String, | ||
| 153 | }, | ||
| 154 | } | ||
| 155 | |||
| 156 | /// Discovers rooted notebook topology within caller-specified work and size limits. | ||
| 157 | /// Reserved `_onefiles` directories contain payloads, not section groups. | ||
| 158 | pub fn discover(source: &mut impl Source, limits: Limits) -> Result<Folder, Error> { | ||
| 159 | let mut remaining = limits.entries; | ||
| 160 | let mut identities = BTreeMap::new(); | ||
| 161 | scan(source, "", &limits, 0, &mut remaining, &mut identities) | ||
| 162 | } | ||
| 163 | |||
| 164 | fn scan( | ||
| 165 | source: &mut impl Source, | ||
| 166 | path: &str, | ||
| 167 | limits: &Limits, | ||
| 168 | depth: usize, | ||
| 169 | remaining: &mut usize, | ||
| 170 | identities: &mut BTreeMap<[u8; 16], String>, | ||
| 171 | ) -> Result<Folder, Error> { | ||
| 172 | if depth > limits.depth { | ||
| 173 | return Err(Error::Limit { path: path.into() }); | ||
| 174 | } | ||
| 175 | let mut listing = source | ||
| 176 | .entries(path, *remaining) | ||
| 177 | .map_err(|error| Error::Io { | ||
| 178 | path: path.into(), | ||
| 179 | error, | ||
| 180 | })?; | ||
| 181 | *remaining = remaining | ||
| 182 | .checked_sub(listing.len()) | ||
| 183 | .ok_or_else(|| Error::Limit { path: path.into() })?; | ||
| 184 | listing.sort(); | ||
| 185 | let mut names = BTreeSet::new(); | ||
| 186 | for entry in &listing { | ||
| 187 | if !component(&entry.name) || !names.insert(&entry.name) { | ||
| 188 | return Err(Error::Entry { | ||
| 189 | path: join(path, &entry.name), | ||
| 190 | }); | ||
| 191 | } | ||
| 192 | } | ||
| 193 | let mut result = Folder { | ||
| 194 | path: path.into(), | ||
| 195 | toc: None, | ||
| 196 | sections: Vec::new(), | ||
| 197 | groups: Vec::new(), | ||
| 198 | }; | ||
| 199 | for entry in &listing { | ||
| 200 | let child = join(path, &entry.name); | ||
| 201 | if entry.kind == EntryKind::Directory { | ||
| 202 | if entry.name.to_ascii_lowercase().ends_with("_onefiles") { | ||
| 203 | continue; | ||
| 204 | } | ||
| 205 | result.groups.push(scan( | ||
| 206 | source, | ||
| 207 | &child, | ||
| 208 | limits, | ||
| 209 | depth + 1, | ||
| 210 | remaining, | ||
| 211 | identities, | ||
| 212 | )?); | ||
| 213 | continue; | ||
| 214 | } | ||
| 215 | let lower = entry.name.to_ascii_lowercase(); | ||
| 216 | let expected = if lower.ends_with(".one") { | ||
| 217 | FileType::Section | ||
| 218 | } else if lower.ends_with(".onetoc2") { | ||
| 219 | FileType::TableOfContents | ||
| 220 | } else { | ||
| 221 | continue; | ||
| 222 | }; | ||
| 223 | if entry.kind != EntryKind::File | ||
| 224 | || (expected == FileType::TableOfContents && result.toc.is_some()) | ||
| 225 | { | ||
| 226 | return Err(Error::Entry { path: child }); | ||
| 227 | } | ||
| 228 | let bytes = source | ||
| 229 | .read(&child, limits.bytes_per_file) | ||
| 230 | .map_err(|error| Error::Io { | ||
| 231 | path: child.clone(), | ||
| 232 | error, | ||
| 233 | })?; | ||
| 234 | if bytes.len() > limits.bytes_per_file { | ||
| 235 | return Err(Error::Limit { path: child }); | ||
| 236 | } | ||
| 237 | let store = Store::parse(&bytes).map_err(|error| Error::Document { | ||
| 238 | path: child.clone(), | ||
| 239 | error, | ||
| 240 | })?; | ||
| 241 | if store.header.file_type != expected || !store.checksum_mismatches.is_empty() { | ||
| 242 | return Err(Error::Document { | ||
| 243 | path: child, | ||
| 244 | error: onestore::Error { | ||
| 245 | offset: 0, | ||
| 246 | message: "Unexpected file type or checksum mismatch", | ||
| 247 | }, | ||
| 248 | }); | ||
| 249 | } | ||
| 250 | let file_id = store.header.file_id; | ||
| 251 | let parsed = (|| { | ||
| 252 | let index = RevisionIndex::parse(&store)?; | ||
| 253 | let document = Document::parse(&index)?; | ||
| 254 | let revision = document.active(document.root)?; | ||
| 255 | if expected == FileType::Section { | ||
| 256 | if revision | ||
| 257 | .roots | ||
| 258 | .get(&1) | ||
| 259 | .and_then(|id| revision.nodes.get(id)) | ||
| 260 | .is_some_and(|node| matches!(node.kind, Kind::Encrypted { .. })) | ||
| 261 | { | ||
| 262 | result.sections.push(Section { | ||
| 263 | path: child.clone(), | ||
| 264 | file_id, | ||
| 265 | state: SectionState::Locked, | ||
| 266 | }); | ||
| 267 | return Ok(()); | ||
| 268 | } | ||
| 269 | index.validate_current()?; | ||
| 270 | document.pages()?; | ||
| 271 | let name = revision | ||
| 272 | .roots | ||
| 273 | .get(&2) | ||
| 274 | .and_then(|id| revision.nodes.get(id)) | ||
| 275 | .and_then(|node| match &node.kind { | ||
| 276 | Kind::SectionMetadata { name, .. } => name.clone(), | ||
| 277 | _ => None, | ||
| 278 | }); | ||
| 279 | result.sections.push(Section { | ||
| 280 | path: child.clone(), | ||
| 281 | file_id, | ||
| 282 | state: SectionState::Readable { name }, | ||
| 283 | }); | ||
| 284 | } else { | ||
| 285 | index.validate_current()?; | ||
| 286 | let node = revision.roots.get(&1).and_then(|id| revision.nodes.get(id)); | ||
| 287 | let Some(Kind::Toc { entries, .. }) = node.map(|node| &node.kind) else { | ||
| 288 | return Err(onestore::Error { | ||
| 289 | offset: 0, | ||
| 290 | message: "Missing notebook TOC root", | ||
| 291 | }); | ||
| 292 | }; | ||
| 293 | let mut seen = BTreeSet::new(); | ||
| 294 | let mut unresolved = Vec::new(); | ||
| 295 | for id in entries { | ||
| 296 | let Some(Kind::Toc { | ||
| 297 | filename, | ||
| 298 | identity: Some(file), | ||
| 299 | order: Some(order), | ||
| 300 | .. | ||
| 301 | }) = revision.nodes.get(id).map(|node| &node.kind) | ||
| 302 | else { | ||
| 303 | return Err(onestore::Error { | ||
| 304 | offset: 0, | ||
| 305 | message: "Incomplete notebook TOC reference", | ||
| 306 | }); | ||
| 307 | }; | ||
| 308 | if filename.as_deref().is_some_and(|name| !component(name)) | ||
| 309 | || !seen.insert(*file) | ||
| 310 | { | ||
| 311 | return Err(onestore::Error { | ||
| 312 | offset: 0, | ||
| 313 | message: "Invalid or duplicated notebook TOC reference", | ||
| 314 | }); | ||
| 315 | } | ||
| 316 | unresolved.push(TocReference { | ||
| 317 | filename: filename.clone(), | ||
| 318 | file: *file, | ||
| 319 | order: *order, | ||
| 320 | }); | ||
| 321 | } | ||
| 322 | result.toc = Some(Toc { | ||
| 323 | filename: entry.name.clone(), | ||
| 324 | file_id, | ||
| 325 | unresolved, | ||
| 326 | }); | ||
| 327 | } | ||
| 328 | Ok(()) | ||
| 329 | })(); | ||
| 330 | if let Err(error) = parsed { | ||
| 331 | if expected == FileType::Section { | ||
| 332 | result.sections.push(Section { | ||
| 333 | path: child.clone(), | ||
| 334 | file_id, | ||
| 335 | state: SectionState::Unreadable(error), | ||
| 336 | }); | ||
| 337 | } else { | ||
| 338 | return Err(Error::Document { path: child, error }); | ||
| 339 | } | ||
| 340 | } | ||
| 341 | if let Some(first) = identities.insert(file_id, child.clone()) { | ||
| 342 | return Err(Error::DuplicateIdentity { | ||
| 343 | file: file_id, | ||
| 344 | first, | ||
| 345 | second: child, | ||
| 346 | }); | ||
| 347 | } | ||
| 348 | } | ||
| 349 | let order: BTreeMap<_, _> = result | ||
| 350 | .toc | ||
| 351 | .iter() | ||
| 352 | .flat_map(|toc| &toc.unresolved) | ||
| 353 | .map(|entry| (entry.file, entry.order)) | ||
| 354 | .collect(); | ||
| 355 | result.sections.sort_by(|a, b| { | ||
| 356 | (order.get(&a.file_id).copied().unwrap_or(u32::MAX), &a.path) | ||
| 357 | .cmp(&(order.get(&b.file_id).copied().unwrap_or(u32::MAX), &b.path)) | ||
| 358 | }); | ||
| 359 | result.groups.sort_by(|a, b| { | ||
| 360 | ( | ||
| 361 | a.toc | ||
| 362 | .as_ref() | ||
| 363 | .and_then(|toc| order.get(&toc.file_id).copied()) | ||
| 364 | .unwrap_or(u32::MAX), | ||
| 365 | &a.path, | ||
| 366 | ) | ||
| 367 | .cmp(&( | ||
| 368 | b.toc | ||
| 369 | .as_ref() | ||
| 370 | .and_then(|toc| order.get(&toc.file_id).copied()) | ||
| 371 | .unwrap_or(u32::MAX), | ||
| 372 | &b.path, | ||
| 373 | )) | ||
| 374 | }); | ||
| 375 | let present: BTreeSet<_> = result | ||
| 376 | .sections | ||
| 377 | .iter() | ||
| 378 | .map(|section| section.file_id) | ||
| 379 | .chain( | ||
| 380 | result | ||
| 381 | .groups | ||
| 382 | .iter() | ||
| 383 | .filter_map(|group| group.toc.as_ref().map(|toc| toc.file_id)), | ||
| 384 | ) | ||
| 385 | .collect(); | ||
| 386 | if let Some(toc) = &mut result.toc { | ||
| 387 | toc.unresolved | ||
| 388 | .retain(|entry| !present.contains(&entry.file)); | ||
| 389 | } | ||
| 390 | let mut observed = source | ||
| 391 | .entries(path, listing.len()) | ||
| 392 | .map_err(|error| Error::Io { | ||
| 393 | path: path.into(), | ||
| 394 | error, | ||
| 395 | })?; | ||
| 396 | observed.sort(); | ||
| 397 | if observed != listing { | ||
| 398 | return Err(Error::Changed { path: path.into() }); | ||
| 399 | } | ||
| 400 | Ok(result) | ||
| 401 | } | ||
| 402 | |||
| 403 | fn component(name: &str) -> bool { | ||
| 404 | !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) | ||
| 405 | } | ||
| 406 | |||
| 407 | fn join(parent: &str, name: &str) -> String { | ||
| 408 | if parent.is_empty() { | ||
| 409 | name.into() | ||
| 410 | } else { | ||
| 411 | format!("{parent}/{name}") | ||
| 412 | } | ||
| 413 | } | ||
crates/onestore-notebook/src/source.rs deleted-121| ... | @@ -1,121 +0,0 @@ | ||
| 1 | use super::{Entry, EntryKind, Source, component}; | ||
| 2 | use std::{ | ||
| 3 | io, | ||
| 4 | path::{Path, PathBuf}, | ||
| 5 | }; | ||
| 6 | |||
| 7 | /// A canonical local root; symbolic-link entries are not traversed. | ||
| 8 | pub struct Local { | ||
| 9 | root: PathBuf, | ||
| 10 | } | ||
| 11 | |||
| 12 | impl Local { | ||
| 13 | pub fn open(root: impl AsRef<Path>) -> io::Result<Self> { | ||
| 14 | let root = root.as_ref().canonicalize()?; | ||
| 15 | if !root.is_dir() { | ||
| 16 | return Err(io::ErrorKind::NotADirectory.into()); | ||
| 17 | } | ||
| 18 | Ok(Self { root }) | ||
| 19 | } | ||
| 20 | |||
| 21 | fn path(&self, relative: &str) -> io::Result<PathBuf> { | ||
| 22 | if !relative.is_empty() && !relative.split('/').all(component) { | ||
| 23 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 24 | } | ||
| 25 | let path = self.root.join(relative).canonicalize()?; | ||
| 26 | if !path.starts_with(&self.root) { | ||
| 27 | return Err(io::ErrorKind::PermissionDenied.into()); | ||
| 28 | } | ||
| 29 | Ok(path) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | impl Source for Local { | ||
| 34 | fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> { | ||
| 35 | let mut entries = Vec::new(); | ||
| 36 | for entry in std::fs::read_dir(self.path(path)?)? { | ||
| 37 | let entry = entry?; | ||
| 38 | if entries.len() == limit { | ||
| 39 | return Err(io::ErrorKind::FileTooLarge.into()); | ||
| 40 | } | ||
| 41 | let name = entry | ||
| 42 | .file_name() | ||
| 43 | .into_string() | ||
| 44 | .map_err(|_| io::ErrorKind::InvalidData)?; | ||
| 45 | let kind = entry.file_type()?; | ||
| 46 | entries.push(Entry { | ||
| 47 | name, | ||
| 48 | kind: if kind.is_dir() { | ||
| 49 | EntryKind::Directory | ||
| 50 | } else if kind.is_file() { | ||
| 51 | EntryKind::File | ||
| 52 | } else { | ||
| 53 | EntryKind::Other | ||
| 54 | }, | ||
| 55 | }); | ||
| 56 | } | ||
| 57 | Ok(entries) | ||
| 58 | } | ||
| 59 | |||
| 60 | fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 61 | onestore::read_file_limited(self.path(path)?, limit) | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | #[cfg(feature = "smb")] | ||
| 66 | /// A share-relative root on an existing blocking SMB connection. | ||
| 67 | pub struct Smb<'a> { | ||
| 68 | client: &'a onestore_smb::Client, | ||
| 69 | root: String, | ||
| 70 | } | ||
| 71 | |||
| 72 | #[cfg(feature = "smb")] | ||
| 73 | impl<'a> Smb<'a> { | ||
| 74 | pub fn new(client: &'a onestore_smb::Client, root: &str) -> io::Result<Self> { | ||
| 75 | let root = root.replace('\\', "/"); | ||
| 76 | if !root.is_empty() && !root.split('/').all(component) { | ||
| 77 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 78 | } | ||
| 79 | Ok(Self { client, root }) | ||
| 80 | } | ||
| 81 | |||
| 82 | fn path(&self, relative: &str) -> io::Result<String> { | ||
| 83 | if !relative.is_empty() && !relative.split('/').all(component) { | ||
| 84 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 85 | } | ||
| 86 | Ok(if relative.is_empty() { | ||
| 87 | self.root.clone() | ||
| 88 | } else { | ||
| 89 | super::join(&self.root, relative) | ||
| 90 | }) | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | #[cfg(feature = "smb")] | ||
| 95 | impl Source for Smb<'_> { | ||
| 96 | fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> { | ||
| 97 | Ok(self | ||
| 98 | .client | ||
| 99 | .read_dir(&self.path(path)?, limit)? | ||
| 100 | .into_iter() | ||
| 101 | .map(|entry| Entry { | ||
| 102 | name: entry.name, | ||
| 103 | kind: if entry.attributes & 0x400 != 0 { | ||
| 104 | EntryKind::Other | ||
| 105 | } else if entry.attributes & 0x10 != 0 { | ||
| 106 | EntryKind::Directory | ||
| 107 | } else { | ||
| 108 | EntryKind::File | ||
| 109 | }, | ||
| 110 | }) | ||
| 111 | .collect()) | ||
| 112 | } | ||
| 113 | |||
| 114 | fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 115 | self.client.read_storage(&self.path(path)?, limit) | ||
| 116 | } | ||
| 117 | |||
| 118 | fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 119 | self.client.read_asset(&self.path(path)?, limit) | ||
| 120 | } | ||
| 121 | } | ||
crates/onestore-notebook/tests/assets.rs deleted-162| ... | @@ -1,162 +0,0 @@ | ||
| 1 | use onestore_notebook::{Error, Local, read_external_asset}; | ||
| 2 | use std::{fs, io}; | ||
| 3 | |||
| 4 | #[test] | ||
| 5 | fn nested_external_payloads_are_exact_and_empty_is_distinct_from_missing() { | ||
| 6 | let root = tempfile::tempdir().unwrap(); | ||
| 7 | let directory = root.path().join("Group 🦀/Section é_onefiles"); | ||
| 8 | fs::create_dir_all(&directory).unwrap(); | ||
| 9 | let filename = "2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin"; | ||
| 10 | let mut source = Local::open(root.path()).unwrap(); | ||
| 11 | let section = "Group 🦀/Section é.ONE"; | ||
| 12 | assert!( | ||
| 13 | matches!(read_external_asset(&mut source, section, filename, 0), | ||
| 14 | Err(Error::Io { error, .. }) if error.kind() == io::ErrorKind::NotFound) | ||
| 15 | ); | ||
| 16 | fs::write(directory.join(filename), []).unwrap(); | ||
| 17 | assert!( | ||
| 18 | read_external_asset(&mut source, section, filename, 0) | ||
| 19 | .unwrap() | ||
| 20 | .is_empty() | ||
| 21 | ); | ||
| 22 | let bytes: Vec<_> = (0..65537).map(|index| (index % 251) as u8).collect(); | ||
| 23 | fs::write(directory.join(filename), &bytes).unwrap(); | ||
| 24 | assert!( | ||
| 25 | matches!(read_external_asset(&mut source, section, filename, bytes.len()-1), | ||
| 26 | Err(Error::Io { error, .. }) if error.kind() == io::ErrorKind::FileTooLarge) | ||
| 27 | ); | ||
| 28 | assert_eq!( | ||
| 29 | read_external_asset(&mut source, section, filename, bytes.len()).unwrap(), | ||
| 30 | bytes | ||
| 31 | ); | ||
| 32 | assert_eq!(fs::read(directory.join(filename)).unwrap(), bytes); | ||
| 33 | } | ||
| 34 | |||
| 35 | #[test] | ||
| 36 | fn invalid_asset_locations_fail_before_accessing_the_source() { | ||
| 37 | struct Unused; | ||
| 38 | impl onestore_notebook::Source for Unused { | ||
| 39 | fn entries(&mut self, _: &str, _: usize) -> io::Result<Vec<onestore_notebook::Entry>> { | ||
| 40 | panic!("Unexpected enumeration") | ||
| 41 | } | ||
| 42 | fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> { | ||
| 43 | panic!("Unexpected read") | ||
| 44 | } | ||
| 45 | } | ||
| 46 | let filename = "2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin"; | ||
| 47 | for section in [ | ||
| 48 | "", | ||
| 49 | ".one", | ||
| 50 | "Group/.one", | ||
| 51 | "/Section.one", | ||
| 52 | "../Section.one", | ||
| 53 | "Group/../Section.one", | ||
| 54 | "Group\\Section.one", | ||
| 55 | "Section.onetoc2", | ||
| 56 | ] { | ||
| 57 | assert!(matches!( | ||
| 58 | read_external_asset(&mut Unused, section, filename, 100), | ||
| 59 | Err(Error::Entry { .. }) | ||
| 60 | )); | ||
| 61 | } | ||
| 62 | for name in [ | ||
| 63 | "", | ||
| 64 | "attachment.png", | ||
| 65 | "../2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin", | ||
| 66 | "2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin:other", | ||
| 67 | "<invfdo>", | ||
| 68 | ] { | ||
| 69 | assert!(matches!( | ||
| 70 | read_external_asset(&mut Unused, "Section.one", name, 100), | ||
| 71 | Err(Error::Entry { .. }) | ||
| 72 | )); | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | #[test] | ||
| 77 | fn reserved_payload_directories_are_not_notebook_groups() { | ||
| 78 | let root = tempfile::tempdir().unwrap(); | ||
| 79 | fs::write( | ||
| 80 | root.path().join("Section.ONE"), | ||
| 81 | onestore::create_section("Section.one", "Fixture", "Author").unwrap(), | ||
| 82 | ) | ||
| 83 | .unwrap(); | ||
| 84 | for name in ["section_onefiles", "orphan_onefiles", "ordinary"] { | ||
| 85 | fs::create_dir(root.path().join(name)).unwrap(); | ||
| 86 | } | ||
| 87 | fs::write( | ||
| 88 | root.path().join("section_onefiles/unfinished.onebin"), | ||
| 89 | b"payload", | ||
| 90 | ) | ||
| 91 | .unwrap(); | ||
| 92 | let catalog = onestore_notebook::discover( | ||
| 93 | &mut Local::open(root.path()).unwrap(), | ||
| 94 | onestore_notebook::Limits { | ||
| 95 | entries: 4, | ||
| 96 | bytes_per_file: 1 << 20, | ||
| 97 | depth: 1, | ||
| 98 | }, | ||
| 99 | ) | ||
| 100 | .unwrap(); | ||
| 101 | assert_eq!(catalog.sections.len(), 1); | ||
| 102 | assert_eq!( | ||
| 103 | catalog | ||
| 104 | .groups | ||
| 105 | .iter() | ||
| 106 | .map(|group| group.path.as_str()) | ||
| 107 | .collect::<Vec<_>>(), | ||
| 108 | ["ordinary"] | ||
| 109 | ); | ||
| 110 | } | ||
| 111 | |||
| 112 | #[test] | ||
| 113 | #[cfg(feature = "smb")] | ||
| 114 | #[ignore = "requires a disposable Samba mirror of corpus/native-external-assets/notebook at ONESTORE_SMB_NOTEBOOK"] | ||
| 115 | fn live_external_payloads() { | ||
| 116 | use onestore::{ | ||
| 117 | FileDataReference, RevisionIndex, Store, | ||
| 118 | document::{Document, Kind}, | ||
| 119 | }; | ||
| 120 | let root = std::path::Path::new("../../corpus/native-external-assets/notebook"); | ||
| 121 | let bytes = fs::read(root.join("synthetic.one")).unwrap(); | ||
| 122 | let store = Store::parse(&bytes).unwrap(); | ||
| 123 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 124 | let document = Document::parse(&index).unwrap(); | ||
| 125 | let client = onestore_smb::Client::connect( | ||
| 126 | &std::env::var("ONESTORE_SMB_LAB").unwrap(), | ||
| 127 | "agent", | ||
| 128 | onestore_smb::Credentials::default(), | ||
| 129 | std::time::Duration::from_secs(5), | ||
| 130 | ) | ||
| 131 | .unwrap(); | ||
| 132 | let mut remote = | ||
| 133 | onestore_notebook::Smb::new(&client, &std::env::var("ONESTORE_SMB_NOTEBOOK").unwrap()) | ||
| 134 | .unwrap(); | ||
| 135 | let mut local = Local::open(root).unwrap(); | ||
| 136 | let mut seen = std::collections::BTreeSet::new(); | ||
| 137 | for node in document | ||
| 138 | .spaces | ||
| 139 | .values() | ||
| 140 | .flat_map(|space| space.revisions.values()) | ||
| 141 | .flat_map(|revision| revision.nodes.values()) | ||
| 142 | { | ||
| 143 | if let Kind::File { | ||
| 144 | reference: FileDataReference::External(filename), | ||
| 145 | .. | ||
| 146 | } = &node.kind | ||
| 147 | && seen.insert(filename) | ||
| 148 | { | ||
| 149 | let expected = fs::read(root.join("synthetic_onefiles").join(filename)).unwrap(); | ||
| 150 | assert_eq!( | ||
| 151 | read_external_asset(&mut local, "synthetic.one", filename, expected.len()).unwrap(), | ||
| 152 | expected | ||
| 153 | ); | ||
| 154 | assert_eq!( | ||
| 155 | read_external_asset(&mut remote, "synthetic.one", filename, expected.len()) | ||
| 156 | .unwrap(), | ||
| 157 | expected | ||
| 158 | ); | ||
| 159 | } | ||
| 160 | } | ||
| 161 | assert_eq!(seen.len(), 3); | ||
| 162 | } | ||
crates/onestore-notebook/tests/discovery.rs deleted-280| ... | @@ -1,280 +0,0 @@ | ||
| 1 | use onestore_notebook::{Entry, Error, Limits, Local, Source, discover}; | ||
| 2 | use std::{fs, io, path::Path}; | ||
| 3 | |||
| 4 | fn limits() -> Limits { | ||
| 5 | Limits { | ||
| 6 | entries: 1000, | ||
| 7 | bytes_per_file: 16 * 1024 * 1024, | ||
| 8 | depth: 16, | ||
| 9 | } | ||
| 10 | } | ||
| 11 | |||
| 12 | fn fixture(root: &Path) -> Vec<u8> { | ||
| 13 | let section = onestore::create_section("one.one", "Retained 🦀", "Fixture").unwrap(); | ||
| 14 | fs::write(root.join("one.one"), &section).unwrap(); | ||
| 15 | let identity = onestore::Store::parse(&section).unwrap().header.file_id; | ||
| 16 | let toc = onestore::create_table_of_contents("Open Notebook.onetoc2", &[("one.one", identity)]) | ||
| 17 | .unwrap(); | ||
| 18 | fs::write(root.join("Open Notebook.onetoc2"), toc).unwrap(); | ||
| 19 | section | ||
| 20 | } | ||
| 21 | |||
| 22 | #[test] | ||
| 23 | fn rename_preserves_identity_and_does_not_trust_a_stale_cached_filename() { | ||
| 24 | let root = tempfile::tempdir().unwrap(); | ||
| 25 | let section = fixture(root.path()); | ||
| 26 | let mut source = Local::open(root.path()).unwrap(); | ||
| 27 | let before = discover(&mut source, limits()).unwrap(); | ||
| 28 | fs::rename( | ||
| 29 | root.path().join("one.one"), | ||
| 30 | root.path().join("Renamed 🦀.ONE"), | ||
| 31 | ) | ||
| 32 | .unwrap(); | ||
| 33 | let after = discover(&mut source, limits()).unwrap(); | ||
| 34 | assert_eq!(before.sections[0].file_id, after.sections[0].file_id); | ||
| 35 | assert_eq!(after.sections[0].path, "Renamed 🦀.ONE"); | ||
| 36 | assert!(after.toc.as_ref().unwrap().unresolved.is_empty()); | ||
| 37 | assert_eq!( | ||
| 38 | fs::read(root.path().join("Renamed 🦀.ONE")).unwrap(), | ||
| 39 | section | ||
| 40 | ); | ||
| 41 | fs::write( | ||
| 42 | root.path().join("one.one"), | ||
| 43 | onestore::create_section("one.one", "Different", "Fixture").unwrap(), | ||
| 44 | ) | ||
| 45 | .unwrap(); | ||
| 46 | let with_replacement = discover(&mut source, limits()).unwrap(); | ||
| 47 | assert_eq!( | ||
| 48 | with_replacement.sections[0].file_id, | ||
| 49 | before.sections[0].file_id | ||
| 50 | ); | ||
| 51 | assert_eq!(with_replacement.sections[0].path, "Renamed 🦀.ONE"); | ||
| 52 | assert_ne!( | ||
| 53 | with_replacement.sections[1].file_id, | ||
| 54 | before.sections[0].file_id | ||
| 55 | ); | ||
| 56 | fs::remove_file(root.path().join("Renamed 🦀.ONE")).unwrap(); | ||
| 57 | let absent = discover(&mut source, limits()).unwrap(); | ||
| 58 | assert_eq!(absent.toc.as_ref().unwrap().unresolved.len(), 1); | ||
| 59 | assert_eq!( | ||
| 60 | absent.toc.as_ref().unwrap().unresolved[0].file, | ||
| 61 | before.sections[0].file_id | ||
| 62 | ); | ||
| 63 | assert_ne!( | ||
| 64 | absent.sections[0].file_id, | ||
| 65 | absent.toc.as_ref().unwrap().unresolved[0].file | ||
| 66 | ); | ||
| 67 | } | ||
| 68 | |||
| 69 | #[test] | ||
| 70 | fn copied_identities_are_ambiguous_even_across_different_groups() { | ||
| 71 | let root = tempfile::tempdir().unwrap(); | ||
| 72 | fixture(root.path()); | ||
| 73 | fs::create_dir(root.path().join("group")).unwrap(); | ||
| 74 | fs::copy( | ||
| 75 | root.path().join("one.one"), | ||
| 76 | root.path().join("group/other.one"), | ||
| 77 | ) | ||
| 78 | .unwrap(); | ||
| 79 | assert!( | ||
| 80 | matches!(discover(&mut Local::open(root.path()).unwrap(), limits()), | ||
| 81 | Err(Error::DuplicateIdentity { first, second, .. }) | ||
| 82 | if [first.as_str(), second.as_str()].contains(&"one.one") | ||
| 83 | && [first.as_str(), second.as_str()].contains(&"group/other.one")) | ||
| 84 | ); | ||
| 85 | } | ||
| 86 | |||
| 87 | #[test] | ||
| 88 | fn locked_and_unreadable_sections_retain_their_storage_identity() { | ||
| 89 | let root = tempfile::tempdir().unwrap(); | ||
| 90 | for (name, fixture) in [ | ||
| 91 | ( | ||
| 92 | "locked.one", | ||
| 93 | "native-encrypted/encrypted-01/notebook/synthetic.one", | ||
| 94 | ), | ||
| 95 | ( | ||
| 96 | "stub.one", | ||
| 97 | "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one", | ||
| 98 | ), | ||
| 99 | ] { | ||
| 100 | fs::copy( | ||
| 101 | Path::new("../../corpus").join(fixture), | ||
| 102 | root.path().join(name), | ||
| 103 | ) | ||
| 104 | .unwrap(); | ||
| 105 | } | ||
| 106 | let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap(); | ||
| 107 | assert!(catalog.toc.is_none()); | ||
| 108 | assert!(matches!( | ||
| 109 | catalog.sections[0].state, | ||
| 110 | onestore_notebook::SectionState::Locked | ||
| 111 | )); | ||
| 112 | assert!(matches!( | ||
| 113 | catalog.sections[1].state, | ||
| 114 | onestore_notebook::SectionState::Unreadable(_) | ||
| 115 | )); | ||
| 116 | for section in catalog.sections { | ||
| 117 | let bytes = fs::read(root.path().join(section.path)).unwrap(); | ||
| 118 | assert_eq!( | ||
| 119 | section.file_id, | ||
| 120 | onestore::Store::parse(&bytes).unwrap().header.file_id | ||
| 121 | ); | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | #[test] | ||
| 126 | fn a_group_rename_retains_toc_identity_and_parent_order() { | ||
| 127 | let root = tempfile::tempdir().unwrap(); | ||
| 128 | let native = Path::new("../../corpus/m6/native-features-01/notebook"); | ||
| 129 | for relative in [ | ||
| 130 | "Open Notebook.onetoc2", | ||
| 131 | "Group A/Open Notebook.onetoc2", | ||
| 132 | "Group A/Duplicate.one", | ||
| 133 | "Group B/Open Notebook.onetoc2", | ||
| 134 | "Group B/Nested/Open Notebook.onetoc2", | ||
| 135 | "Group B/Nested/Duplicate.one", | ||
| 136 | ] { | ||
| 137 | let target = root.path().join(relative); | ||
| 138 | fs::create_dir_all(target.parent().unwrap()).unwrap(); | ||
| 139 | fs::copy(native.join(relative), target).unwrap(); | ||
| 140 | } | ||
| 141 | let before = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap(); | ||
| 142 | assert_eq!(before.groups[0].path, "Group A"); | ||
| 143 | fs::rename(root.path().join("Group A"), root.path().join("Renamed 🦀")).unwrap(); | ||
| 144 | let catalog = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap(); | ||
| 145 | assert_eq!(catalog.groups[0].path, "Renamed 🦀"); | ||
| 146 | assert_eq!( | ||
| 147 | catalog.groups[0].toc.as_ref().unwrap().file_id, | ||
| 148 | before.groups[0].toc.as_ref().unwrap().file_id | ||
| 149 | ); | ||
| 150 | assert_eq!(catalog.groups[1].path, "Group B"); | ||
| 151 | assert_eq!( | ||
| 152 | catalog.groups[0].sections[0].path, | ||
| 153 | "Renamed 🦀/Duplicate.one" | ||
| 154 | ); | ||
| 155 | assert_eq!( | ||
| 156 | serde_json::to_value(&catalog.toc.unwrap().unresolved).unwrap(), | ||
| 157 | serde_json::to_value(&before.toc.unwrap().unresolved).unwrap() | ||
| 158 | ); | ||
| 159 | } | ||
| 160 | |||
| 161 | #[test] | ||
| 162 | fn limits_and_incomplete_files_do_not_produce_a_catalog() { | ||
| 163 | let root = tempfile::tempdir().unwrap(); | ||
| 164 | fixture(root.path()); | ||
| 165 | let mut source = Local::open(root.path()).unwrap(); | ||
| 166 | assert!( | ||
| 167 | discover( | ||
| 168 | &mut source, | ||
| 169 | Limits { | ||
| 170 | entries: 1, | ||
| 171 | ..limits() | ||
| 172 | } | ||
| 173 | ) | ||
| 174 | .is_err() | ||
| 175 | ); | ||
| 176 | assert!( | ||
| 177 | discover( | ||
| 178 | &mut source, | ||
| 179 | Limits { | ||
| 180 | bytes_per_file: 8, | ||
| 181 | ..limits() | ||
| 182 | } | ||
| 183 | ) | ||
| 184 | .is_err() | ||
| 185 | ); | ||
| 186 | fs::create_dir(root.path().join("group")).unwrap(); | ||
| 187 | assert!(matches!( | ||
| 188 | discover( | ||
| 189 | &mut source, | ||
| 190 | Limits { | ||
| 191 | depth: 0, | ||
| 192 | ..limits() | ||
| 193 | } | ||
| 194 | ), | ||
| 195 | Err(Error::Limit { .. }) | ||
| 196 | )); | ||
| 197 | fs::write(root.path().join("one.one"), b"unfinished").unwrap(); | ||
| 198 | assert!( | ||
| 199 | matches!(discover(&mut source, limits()), Err(Error::Document { path, .. }) if path == "one.one") | ||
| 200 | ); | ||
| 201 | } | ||
| 202 | |||
| 203 | #[test] | ||
| 204 | fn a_failed_refresh_retains_the_previous_catalog_as_an_independent_value() { | ||
| 205 | struct Changing { | ||
| 206 | source: Local, | ||
| 207 | reads: usize, | ||
| 208 | fail: bool, | ||
| 209 | } | ||
| 210 | impl Source for Changing { | ||
| 211 | fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> { | ||
| 212 | self.reads += 1; | ||
| 213 | let mut entries = self.source.entries(path, limit)?; | ||
| 214 | if self.reads == 2 { | ||
| 215 | if self.fail { | ||
| 216 | return Err(io::ErrorKind::ConnectionAborted.into()); | ||
| 217 | } | ||
| 218 | entries[0].name.push_str(".renamed"); | ||
| 219 | } | ||
| 220 | Ok(entries) | ||
| 221 | } | ||
| 222 | fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 223 | self.source.read(path, limit) | ||
| 224 | } | ||
| 225 | } | ||
| 226 | let root = tempfile::tempdir().unwrap(); | ||
| 227 | fixture(root.path()); | ||
| 228 | let mut source = Local::open(root.path()).unwrap(); | ||
| 229 | let catalog = discover(&mut source, limits()).unwrap(); | ||
| 230 | let before = serde_json::to_value(&catalog).unwrap(); | ||
| 231 | for fail in [false, true] { | ||
| 232 | let mut changing = Changing { | ||
| 233 | source: Local::open(root.path()).unwrap(), | ||
| 234 | reads: 0, | ||
| 235 | fail, | ||
| 236 | }; | ||
| 237 | let error = discover(&mut changing, limits()).unwrap_err(); | ||
| 238 | if fail { | ||
| 239 | assert!( | ||
| 240 | matches!(error, Error::Io { error, .. } if error.kind() == io::ErrorKind::ConnectionAborted) | ||
| 241 | ); | ||
| 242 | } else { | ||
| 243 | assert!(matches!(error, Error::Changed { .. })); | ||
| 244 | } | ||
| 245 | assert_eq!(serde_json::to_value(&catalog).unwrap(), before); | ||
| 246 | } | ||
| 247 | } | ||
| 248 | |||
| 249 | #[test] | ||
| 250 | fn local_access_stays_inside_the_selected_root() { | ||
| 251 | let root = tempfile::tempdir().unwrap(); | ||
| 252 | fixture(root.path()); | ||
| 253 | let mut source = Local::open(root.path()).unwrap(); | ||
| 254 | for path in [ | ||
| 255 | "../one.one", | ||
| 256 | "/one.one", | ||
| 257 | "x/../one.one", | ||
| 258 | "one.one\0", | ||
| 259 | "x\\one.one", | ||
| 260 | ] { | ||
| 261 | assert_eq!( | ||
| 262 | source.read(path, 100).unwrap_err().kind(), | ||
| 263 | io::ErrorKind::InvalidInput | ||
| 264 | ); | ||
| 265 | } | ||
| 266 | #[cfg(unix)] | ||
| 267 | { | ||
| 268 | let other = tempfile::tempdir().unwrap(); | ||
| 269 | fs::write(other.path().join("other.one"), b"outside").unwrap(); | ||
| 270 | std::os::unix::fs::symlink(other.path().join("other.one"), root.path().join("link.one")) | ||
| 271 | .unwrap(); | ||
| 272 | assert_eq!( | ||
| 273 | source.read("link.one", 100).unwrap_err().kind(), | ||
| 274 | io::ErrorKind::PermissionDenied | ||
| 275 | ); | ||
| 276 | assert!( | ||
| 277 | matches!(discover(&mut source, limits()), Err(Error::Entry { path }) if path == "link.one") | ||
| 278 | ); | ||
| 279 | } | ||
| 280 | } | ||
crates/onestore-offline/Cargo.toml deleted-23| ... | @@ -1,23 +0,0 @@ | ||
| 1 | [package] | ||
| 2 | name = "onestore-offline" | ||
| 3 | version = "0.1.0" | ||
| 4 | edition = "2024" | ||
| 5 | publish = false | ||
| 6 | |||
| 7 | [features] | ||
| 8 | smb = ["dep:onestore-smb", "onestore-notebook/smb"] | ||
| 9 | |||
| 10 | [dependencies] | ||
| 11 | onestore = { path = "../onestore" } | ||
| 12 | onestore-notebook = { path = "../onestore-notebook" } | ||
| 13 | onestore-smb = { path = "../onestore-smb", optional = true } | ||
| 14 | rusqlite = { version = "=0.40.2", features = ["bundled", "backup"] } | ||
| 15 | thiserror = "2" | ||
| 16 | serde = { version = "1", features = ["derive"] } | ||
| 17 | serde_json = "1" | ||
| 18 | tempfile = "3" | ||
| 19 | sha2 = "0.11" | ||
| 20 | |||
| 21 | [[example]] | ||
| 22 | name = "smb_offline_client" | ||
| 23 | required-features = ["smb"] | ||
crates/onestore-offline/README.md deleted-323| ... | @@ -1,323 +0,0 @@ | ||
| 1 | # onestore-offline | ||
| 2 | |||
| 3 | Durable local editing for a OneNote file, in an optional sibling crate. The current | ||
| 4 | foundation stores a complete working image and typed editing intents in a local | ||
| 5 | SQLite database. `sync_once` provides a reconciliation step and `start_sync` owns | ||
| 6 | automatic polling and reconnects. Local success does not | ||
| 7 | acknowledge publication to a shared notebook. | ||
| 8 | |||
| 9 | ```no_run | ||
| 10 | use onestore::ExGuid; | ||
| 11 | use onestore_offline::Replica; | ||
| 12 | # fn example(path: &std::path::Path, source: &[u8], space: ExGuid, text: ExGuid) | ||
| 13 | # -> Result<(), Box<dyn std::error::Error>> { | ||
| 14 | // `space` and `text` are identities from the supplied section's document model. | ||
| 15 | let cache = Replica::create(path, source)?; | ||
| 16 | let snapshot = cache.snapshot()?; | ||
| 17 | let local_id = cache.edit_text(&snapshot, space, text, 0..0, "Offline edit ")?; | ||
| 18 | drop(cache); | ||
| 19 | |||
| 20 | let reopened = Replica::open(path)?; | ||
| 21 | let current = reopened.snapshot()?; | ||
| 22 | let pending = reopened.pending()?; | ||
| 23 | assert_eq!(pending.last().map(|edit| edit.id), local_id); | ||
| 24 | # Ok(()) | ||
| 25 | # } | ||
| 26 | ``` | ||
| 27 | |||
| 28 | `insert` accepts the core library's `Insertion` value and durably retains its | ||
| 29 | intent identities. `Insertion::with_formatting` queues text and | ||
| 30 | its styles as one intent and one publication. Keep that value across retries; its `text_object()` identifies | ||
| 31 | the new text for subsequent offline edits. Pending entries expose | ||
| 32 | `Operation::Text(TextEdit)`, `Operation::Insert(Insertion)`, | ||
| 33 | `Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)`, | ||
| 34 | `Operation::Join(JoinEdit)`, `Operation::Outline(OutlineEdit)`, | ||
| 35 | `Operation::Tree(TreeEdit)`, `Operation::CreatePage(PageCreation)` and | ||
| 36 | `Operation::Pages(PageEdits)` through their | ||
| 37 | `operation` field. Synchronization applies these in queue order, so an inserted | ||
| 38 | outline can precede its paragraphs and their later edits. Missing anchors or | ||
| 39 | existing insertion identities preserve a conflict and the complete local image. | ||
| 40 | |||
| 41 | `create_page` accepts the core `PageCreation` intent and queues its page space | ||
| 42 | and section entry as one publication. Subsequent outlines and title edits use | ||
| 43 | the intent's stable identities immediately after local acknowledgement. | ||
| 44 | Independent page additions rebase against the current section order; duplicate | ||
| 45 | titles remain distinct. An unavailable or no-longer-leading insertion anchor | ||
| 46 | produces `StructureChanged`. `rebase_page_creation_conflict(id, local, remote, | ||
| 47 | before)` reviews a replacement anchor against both cache images while retaining | ||
| 48 | the new page and dependent object identities. Existing page identities require | ||
| 49 | reconciliation; a matching page alone does not establish a receipt. | ||
| 50 | |||
| 51 | `pages(snapshot, edits)` queues a slice of core `PageEdit` intents as one atomic | ||
| 52 | publication. `PagePosition::Keep` preserves remote movement during indentation-only | ||
| 53 | edits. Every requested level remains explicit: a competing remote level produces | ||
| 54 | `StructureChanged` unless it already matches the requested level. Moves compare | ||
| 55 | the selected page's position relative to surviving observed pages; an unchanged | ||
| 56 | or already-satisfied position can proceed, and new remote pages remain present. | ||
| 57 | Indistinguishable competing moves retain a conflict and the complete local image. | ||
| 58 | |||
| 59 | `rebase_pages_conflict(id, local, remote, edits)` reviews the batch against both | ||
| 60 | cache images. Use `PageEdit::reposition(position, level)` on the retained intents | ||
| 61 | to revise anchors or indentation; replacing page or allocated series identities | ||
| 62 | is rejected. Dependent edits remain queued. Schema 11 adds the durable batch; | ||
| 63 | schema 10 migration preserves existing multi-space publication evidence unchanged. | ||
| 64 | Attempt evidence includes every changed space plus the receipt space when that | ||
| 65 | space is unchanged. An existing section revision alone cannot confirm a page edit. | ||
| 66 | The [offline page corpus](../../corpus/page-lifecycle/offline-edits/README.md) | ||
| 67 | contains native comparisons, reproduction commands and stateful sanitizer seeds. | ||
| 68 | |||
| 69 | `split` and `join` accept the core `ParagraphSplit` and `ParagraphJoin` intents. | ||
| 70 | Splits retain allocated identities when the original UTF-16 boundary rebases; | ||
| 71 | dependent edits can address `ParagraphSplit::text_object()` immediately after | ||
| 72 | local acknowledgement. Both operations retain observed parent paths, children, | ||
| 73 | indentation, paragraph/list state and tags. Changed structure produces | ||
| 74 | `StructureChanged`. Remote text changes must leave unambiguous boundary mappings; | ||
| 75 | current remote character styles are preserved. Reconciliation does not silently | ||
| 76 | discard a newly added right tag or move an unobserved child. | ||
| 77 | |||
| 78 | `rebase_conflict` accepts a zero-length reviewed range for a split, preserving its | ||
| 79 | allocated identities. `rebase_join_conflict(id, local, remote)` reviews the original | ||
| 80 | join against current images. It rejects a change in which text identity survives, | ||
| 81 | because dependent edits still address that identity. As with other conflict | ||
| 82 | reviews, stale images, pending operations and uncertain attempts cannot be rebased. | ||
| 83 | An uncertain structural operation requires its original attempted revision for | ||
| 84 | confirmation; matching text or structure does not establish a receipt. | ||
| 85 | |||
| 86 | `rebase_paragraph_conflict(id, local, remote, parent, before)` and | ||
| 87 | `rebase_outline_conflict(id, local, remote, page, x, y)` accept reviewed replacement | ||
| 88 | placements for the oldest insertion conflict. They preserve creation time, text, | ||
| 89 | author and object identities, keeping later edits attached to their original targets. | ||
| 90 | The images must still match the cache; the new placement must be valid in the remote | ||
| 91 | image. Paragraph intents cannot become outlines or vice versa. Pending edits and | ||
| 92 | uncertain publication attempts cannot be repositioned through conflict review. | ||
| 93 | |||
| 94 | ```no_run | ||
| 95 | use onestore::{ExGuid, Insertion, TextAttribute}; | ||
| 96 | use onestore_offline::{EditStatus, Replica}; | ||
| 97 | # fn add_outline(cache: &Replica, space: ExGuid, page: ExGuid) | ||
| 98 | # -> Result<(), Box<dyn std::error::Error>> { | ||
| 99 | let outline = Insertion::outline(page, 144.0, 216.0, "Offline outline", "Author")? | ||
| 100 | .with_formatting(0..7, &[TextAttribute::Bold(true)])?; | ||
| 101 | let id = cache.insert(&cache.snapshot()?, space, &outline)?; | ||
| 102 | if let Some(id) = id { | ||
| 103 | // A running worker may already have advanced this state. | ||
| 104 | match cache.status(id)? { | ||
| 105 | Some(EditStatus::Published { revision }) => println!("{revision}"), | ||
| 106 | state => println!("{state:?}"), | ||
| 107 | } | ||
| 108 | } | ||
| 109 | # Ok(()) | ||
| 110 | # } | ||
| 111 | ``` | ||
| 112 | |||
| 113 | `format` accepts the core `TextAttribute` slice and a UTF-16 range. Its durable intent | ||
| 114 | retains the observed text and selected attribute values. Remote text changes must | ||
| 115 | leave an unambiguous range; independent remote attributes merge, while competing | ||
| 116 | values preserve `FormattingChanged`. A remote value that already matches the | ||
| 117 | requested value is accepted. Enabling superscript or subscript also checks the | ||
| 118 | opposite attribute that the operation clears. If the remote image already satisfies | ||
| 119 | the whole operation, guarded confirmation still precedes a durable receipt. | ||
| 120 | |||
| 121 | `outline` accepts a target object and the core `onestore::OutlineEdit`: outline | ||
| 122 | position/width or a paragraph's saved collapse default. It retains the target's | ||
| 123 | ancestry and the properties the operation changes. Independent content, formatting | ||
| 124 | and layout properties merge; competing selected values produce `LayoutChanged`, | ||
| 125 | and changed ancestry produces `StructureChanged`. Width changes also check the | ||
| 126 | reserved wrapping width that they clear. Missing targets preserve the local branch. | ||
| 127 | `rebase_layout_conflict(id, local, remote)` explicitly reviews the original change | ||
| 128 | against both current images, preserving dependent intents. An uncertain layout | ||
| 129 | attempt requires its original revision; converged values cannot establish its receipt. | ||
| 130 | |||
| 131 | `tree` accepts the core `onestore::TreeEdit`. Moves preserve independent remote | ||
| 132 | text, formatting and descendants. A competing ancestor, indentation or sibling | ||
| 133 | crossing produces `StructureChanged`; unrelated sibling insertions/deletions can | ||
| 134 | merge. An anchor must remain a direct child of the requested destination. An | ||
| 135 | already satisfied move still requires guarded confirmation before acknowledgement. | ||
| 136 | Deletion compares the selected raw property graph, including referenced styles, | ||
| 137 | tags, unknown fields and internal attachments. Changed content produces | ||
| 138 | `ContentChanged`; property order, CompactID numbering and modification timestamps | ||
| 139 | do not affect that comparison. Immutable records compare by content, and empty | ||
| 140 | child lists compare equally to absent child lists. Mutable object identities | ||
| 141 | remain significant. Missing targets retain `TargetUnavailable`. | ||
| 142 | `rebase_tree_conflict(id, local, remote)` reviews the original move/deletion against | ||
| 143 | both current images. An emptied cell's replacement paragraph/text identities must | ||
| 144 | remain the same before replay or review; later queued edits keep their targets. | ||
| 145 | Uncertain tree attempts require their original revision for confirmation, even | ||
| 146 | when an independent move or deletion has the same visible effect. | ||
| 147 | |||
| 148 | Recognized earlier caches migrate transactionally to version ten. Publication | ||
| 149 | attempts retain every changed space's revision; legacy attempts retain their | ||
| 150 | single-space evidence. Earlier recovery archives remain readable without migration. | ||
| 151 | Version-eight | ||
| 152 | deletion observations retain their original identity-sensitive preconditions; | ||
| 153 | explicit conflict review upgrades them to the immutable-content comparison. | ||
| 154 | The migration retains images, local IDs, publication attempts, conflicts, | ||
| 155 | receipts and the autoincrement sequence; it does not reuse acknowledged IDs when | ||
| 156 | the pending queue is empty. | ||
| 157 | |||
| 158 | Share one `Replica` between application threads. Each edit compares its supplied | ||
| 159 | snapshot under the cache transaction; stale snapshots return `Io(ResourceBusy)`. | ||
| 160 | The intent and its resulting image commit together. No-op edits return `None`. | ||
| 161 | Keep the cache on a local filesystem: the connection holds exclusive ownership | ||
| 162 | between transactions, and a second open fails busy. No network wait occurs in a | ||
| 163 | local edit. After a database error, reopen and inspect the durable state before | ||
| 164 | retrying. | ||
| 165 | |||
| 166 | `sync_once(&mut remote)` processes the oldest pending edit through a `Remote` | ||
| 167 | implementation, returning its ID and `EditStatus`. A durable `Published` receipt | ||
| 168 | survives reopening. Publication attempts are recorded before network I/O; a retained | ||
| 169 | attempt requires every recorded revision to remain present, followed by comparison, | ||
| 170 | flushing and refreshed header version | ||
| 171 | metadata before acknowledgement. If a formatting attempt's revision is missing, | ||
| 172 | the complete requested effect can instead be confirmed on a uniquely aligned | ||
| 173 | range; its receipt identifies that confirmed current revision. Otherwise the | ||
| 174 | missing attempt remains `AwaitingConfirmation`. Neither path replays an uncertain | ||
| 175 | publication. Overlapping or ambiguous edits retain `Conflict` status, their complete | ||
| 176 | local image and the last observed remote image returned by `remote_snapshot`. | ||
| 177 | Transport errors return `Error::Remote` or `Error::RemoteIo`; inspect `status(id)` | ||
| 178 | after the error to distinguish a retained attempt from a pending edit or receipt. | ||
| 179 | The error return does not roll back a locally acknowledged intent. | ||
| 180 | |||
| 181 | Rebasing accepts only character mappings shared by every minimum insertion/deletion | ||
| 182 | alignment. UTF-16 ranges must preserve Unicode scalar boundaries. A bounded | ||
| 183 | alignment search also leaves a conflict when it cannot establish a unique mapping. | ||
| 184 | The current operation processes one queue head; while edits remain, `snapshot` | ||
| 185 | preserves the complete local working image. An empty queue can refresh from the | ||
| 186 | remote image. Synchronization holds a separate owner lock, so local edits can | ||
| 187 | continue during network waits; competing synchronization calls return `WouldBlock`. | ||
| 188 | |||
| 189 | `rebase_conflict(id, local, remote, range)` lets a caller explicitly place the | ||
| 190 | oldest conflicting text or formatting intent at a reviewed UTF-16 range in the remote image. | ||
| 191 | The supplied images must still match `snapshot()` and `remote_snapshot()`. | ||
| 192 | It preserves the original replacement or requested attributes, intent ID, complete local working image | ||
| 193 | and every later intent; the selected range and remote paragraph become the | ||
| 194 | intent's new comparison base in one local transaction. Formatting also captures | ||
| 195 | the reviewed attribute values as its new precondition. This operation performs | ||
| 196 | no network I/O, clears the conflict to `Pending`, and wakes the worker. | ||
| 197 | Publication still reads the latest remote image and uses exact guarded comparison; | ||
| 198 | another overlapping remote edit can produce a new conflict. An uncertain attempt | ||
| 199 | cannot be rebased, and selecting text that already equals the replacement does | ||
| 200 | not create a publication acknowledgement. | ||
| 201 | |||
| 202 | With the optional `smb` feature, `SmbRemote::new(client, path, limit)` binds an | ||
| 203 | `onestore_smb::Client` to one share-relative file and snapshot limit. Remote identity uses the logical root | ||
| 204 | object space, which survives the tested native compaction that replaces the file ID. | ||
| 205 | |||
| 206 | An `Arc<Replica>` can own one background worker. Supply a connection factory, poll | ||
| 207 | interval and observer; successful publications drain immediately, durable local | ||
| 208 | edits wake the worker, and `wake()` requests an immediate reachability retry. | ||
| 209 | Transport failures discard the old connection and retry through the factory; | ||
| 210 | Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy` | ||
| 211 | reuse the connection. Contended `NotCommitted` operations use randomized backoff, | ||
| 212 | capped at one second, to separate competing retry cycles. Cancellation interrupts this delay; local wake notifications | ||
| 213 | remain coalesced until its end. | ||
| 214 | `RemoteIo` distinguishes connection/read failures from | ||
| 215 | local `Io` errors. Cache/document errors stop the worker. Observers receive every | ||
| 216 | attempt's result on the worker thread, including unchanged conflict/uncertain | ||
| 217 | statuses; durable edit state remains available through `status`. | ||
| 218 | |||
| 219 | ```no_run | ||
| 220 | # #[cfg(feature = "smb")] | ||
| 221 | # fn example(cache: std::sync::Arc<onestore_offline::Replica>, username: String, password: String) | ||
| 222 | # -> Result<(), Box<dyn std::error::Error>> { | ||
| 223 | use onestore_offline::SmbRemote; | ||
| 224 | use onestore_smb::{Client, Credentials}; | ||
| 225 | use std::time::Duration; | ||
| 226 | |||
| 227 | let worker = cache.start_sync( | ||
| 228 | Duration::from_secs(2), | ||
| 229 | move || { | ||
| 230 | Client::connect( | ||
| 231 | "server:445", "notes", | ||
| 232 | Credentials { username: &username, password: &password, domain: "" }, | ||
| 233 | Duration::from_secs(5), | ||
| 234 | ).map(|client| SmbRemote::new(client, "Personal/Video.one", 64 * 1024 * 1024)) | ||
| 235 | }, | ||
| 236 | |result| { | ||
| 237 | if let Err(error) = result { eprintln!("{error}"); } | ||
| 238 | }, | ||
| 239 | )?; | ||
| 240 | // Retain `worker` while synchronization should run; local edits wake it automatically. | ||
| 241 | worker.stop()?; | ||
| 242 | # Ok(()) | ||
| 243 | # } | ||
| 244 | ``` | ||
| 245 | |||
| 246 | `stop()` cancels future steps and joins the worker, returning a fatal cache error | ||
| 247 | or worker panic. Dropping it requests cancellation without waiting. An in-flight | ||
| 248 | step completes before releasing ownership; a replacement worker cannot start | ||
| 249 | while the old one still owns the replica. Remote operations and callbacks must | ||
| 250 | have bounded execution times if shutdown latency matters. A dropped worker can | ||
| 251 | briefly retain the cache; use `stop()` before requiring an immediate reopen. | ||
| 252 | Cancellation and application restart retain pending edits and publication attempts. | ||
| 253 | Credentials belong to the factory, not the cache database. | ||
| 254 | |||
| 255 | Creation refuses existing paths. Opening recognizes the application identity and | ||
| 256 | schema version, validates database integrity and both notebook images, and rejects | ||
| 257 | unsupported journal modes without converting them. Failed initialization preserves | ||
| 258 | the file for inspection. SQLite uses DELETE journaling, EXTRA synchronization and | ||
| 259 | fullfsync; each required setting is queried back. | ||
| 260 | |||
| 261 | The cache and its pending intents contain notebook content. The core `onestore` | ||
| 262 | crate stays independent of SQLite and network runtimes. Device and simulator | ||
| 263 | examples compile and link for iOS; recorded process-interruption tests do not | ||
| 264 | establish physical power-loss durability. Evidence is tracked in [Milestone 9](../../evidence/MILESTONE9.md). | ||
| 265 | |||
| 266 | The SMB-enabled `smb_offline_client` example is an owned-lab workload for | ||
| 267 | `tools/native_collaboration.py --offline --embedded-smb`. It separates local | ||
| 268 | acknowledgements, remote publication attempts, persisted receipts and cache reopen | ||
| 269 | checks. Its append-specific conflict review policy lives in the test client; | ||
| 270 | the library continues to preserve conflicts requiring an explicit decision. | ||
| 271 | |||
| 272 | ## Recovery archives | ||
| 273 | |||
| 274 | `export_recovery(new_path)` captures both complete notebook images, the typed | ||
| 275 | queue, uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one | ||
| 276 | SQLite snapshot. It refuses existing destinations and leaves the live queue | ||
| 277 | unchanged. Export to a local directory from a background thread: copying holds | ||
| 278 | the cache mutex while capturing the database. Failure after the final rename can | ||
| 279 | leave a complete archive at the requested path; it never acknowledges a remote | ||
| 280 | edit. Archives contain notebook content and use a separate database identity, so | ||
| 281 | `Replica::open` rejects them as writable caches. | ||
| 282 | |||
| 283 | ```no_run | ||
| 284 | use onestore_offline::{Recovery, Replica}; | ||
| 285 | # fn example(cache: &Replica) -> Result<(), Box<dyn std::error::Error>> { | ||
| 286 | cache.export_recovery("review.sqlite")?; | ||
| 287 | let review = Recovery::open("review.sqlite")?; | ||
| 288 | let counts = review.summary()?; | ||
| 289 | let local = review.snapshot()?; | ||
| 290 | let remote = review.remote_snapshot()?; | ||
| 291 | let pending = review.pending()?; | ||
| 292 | let receipts = review.receipts()?; | ||
| 293 | # Ok(()) | ||
| 294 | # } | ||
| 295 | ``` | ||
| 296 | |||
| 297 | `Recovery` provides read-only inspection and no synchronization or restore method. | ||
| 298 | `status(id)` preserves the same state interpretation as the live replica. | ||
| 299 | `recovery_summary()` on the live replica and `summary()` on an archive return | ||
| 300 | counts and byte sizes without notebook text, paths, authors or credentials. | ||
| 301 | Opening an archive validates its schema and images without migration. A recovery | ||
| 302 | archive is evidence for a reviewed recovery decision, not a second active queue. | ||
| 303 | |||
| 304 | ## Downloaded media | ||
| 305 | |||
| 306 | `fetch_asset(source, section, filename, limit)` resolves a declared external | ||
| 307 | payload through `onestore-notebook::Source` and durably caches its exact bytes. | ||
| 308 | The section path is relative to the source root; the filename comes from a | ||
| 309 | `FileDataReference::External` in the retained working or remote image. Local and | ||
| 310 | SMB sources use the same API. Downloads release the cache mutex during network | ||
| 311 | I/O and recheck the reference before committing; edits can continue meanwhile. | ||
| 312 | |||
| 313 | `cached_asset(filename, limit)` reads previously downloaded bytes without network | ||
| 314 | access. `None` means never downloaded; `Some(Vec::new())` is a downloaded empty | ||
| 315 | payload. Each read checks the stored SHA-256 and enforces the byte limit before | ||
| 316 | loading the payload. Cache contents describe the prior download, not current | ||
| 317 | server reachability or presence. A failed fetch returns its error without | ||
| 318 | silently substituting cached bytes. Different bytes for an already cached file | ||
| 319 | identity return `AssetChanged` and preserve the previous download. | ||
| 320 | |||
| 321 | Downloads do not change pending edits, publication attempts or receipts. Recovery | ||
| 322 | archives include cached media and expose the same bounded `cached_asset` lookup. | ||
| 323 | Schema-4 archives remain readable without migration and contain no media cache. | ||
crates/onestore-offline/examples/cache_probe.rs deleted-238| ... | @@ -1,238 +0,0 @@ | ||
| 1 | use onestore::{ | ||
| 2 | ExGuid, Insertion, RevisionIndex, Store, TextAttribute, | ||
| 3 | document::{Document, Kind}, | ||
| 4 | }; | ||
| 5 | use onestore_offline::Replica; | ||
| 6 | use std::{ | ||
| 7 | io::{self, BufRead, Write}, | ||
| 8 | path::Path, | ||
| 9 | }; | ||
| 10 | |||
| 11 | fn content(bytes: &[u8]) -> (ExGuid, ExGuid, String) { | ||
| 12 | let store = Store::parse(bytes).unwrap(); | ||
| 13 | assert!(store.checksum_mismatches.is_empty()); | ||
| 14 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 15 | index.validate_current().unwrap(); | ||
| 16 | let document = Document::parse(&index).unwrap(); | ||
| 17 | document | ||
| 18 | .spaces | ||
| 19 | .iter() | ||
| 20 | .find_map(|(sid, space)| { | ||
| 21 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 22 | revision | ||
| 23 | .nodes | ||
| 24 | .iter() | ||
| 25 | .find_map(|(oid, node)| match &node.kind { | ||
| 26 | Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())), | ||
| 27 | _ => None, | ||
| 28 | }) | ||
| 29 | }) | ||
| 30 | .unwrap() | ||
| 31 | } | ||
| 32 | |||
| 33 | fn payload(operation: u64, size: usize) -> String { | ||
| 34 | format!("{operation}:🦀{}", "x".repeat(size)) | ||
| 35 | } | ||
| 36 | |||
| 37 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 38 | let args: Vec<_> = std::env::args().collect(); | ||
| 39 | let mode = &args[1]; | ||
| 40 | let path = Path::new(&args[2]); | ||
| 41 | let operation_kind = | ||
| 42 | std::env::var("ONESTORE_CACHE_PROBE_OPERATION").unwrap_or_else(|_| "text".into()); | ||
| 43 | assert!(matches!( | ||
| 44 | operation_kind.as_str(), | ||
| 45 | "text" | "insert" | "format" | ||
| 46 | )); | ||
| 47 | let size = match std::env::var("ONESTORE_CACHE_PROBE_BYTES") { | ||
| 48 | Ok(value) => value.parse::<usize>()?, | ||
| 49 | Err(std::env::VarError::NotPresent) => 2 * 1024 * 1024, | ||
| 50 | Err(error) => return Err(error.into()), | ||
| 51 | }; | ||
| 52 | assert!(size > 0 && size <= 2 * 1024 * 1024); | ||
| 53 | let seed = std::env::var_os("ONESTORE_CACHE_PROBE_SOURCE") | ||
| 54 | .map(std::fs::read) | ||
| 55 | .transpose()?; | ||
| 56 | if mode == "init" { | ||
| 57 | let source = match seed { | ||
| 58 | Some(source) => source, | ||
| 59 | None => onestore::create_section( | ||
| 60 | "cache.one", | ||
| 61 | &if operation_kind == "format" { | ||
| 62 | payload(0, size) | ||
| 63 | } else { | ||
| 64 | "Base".into() | ||
| 65 | }, | ||
| 66 | "Fixture", | ||
| 67 | )?, | ||
| 68 | }; | ||
| 69 | Replica::create(path, &source)?; | ||
| 70 | return Ok(()); | ||
| 71 | } | ||
| 72 | let cache = Replica::open(path)?; | ||
| 73 | if mode == "read" { | ||
| 74 | let snapshot = cache.snapshot()?; | ||
| 75 | let base = cache.remote_snapshot()?; | ||
| 76 | let (sid, target, mut expected) = content(&base); | ||
| 77 | let store = Store::parse(&snapshot)?; | ||
| 78 | assert!(store.checksum_mismatches.is_empty()); | ||
| 79 | let index = RevisionIndex::parse(&store)?; | ||
| 80 | index.validate_current()?; | ||
| 81 | let document = Document::parse(&index)?; | ||
| 82 | let space = &document.spaces[&sid]; | ||
| 83 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 84 | let mut operations = Vec::new(); | ||
| 85 | let mut ids = Vec::new(); | ||
| 86 | let mut font_size = None; | ||
| 87 | for pending in cache.pending()? { | ||
| 88 | let operation = match pending.operation { | ||
| 89 | onestore_offline::Operation::Text(edit) => { | ||
| 90 | assert_eq!(operation_kind, "text"); | ||
| 91 | assert_eq!(edit.object, target); | ||
| 92 | assert_eq!(edit.before, expected); | ||
| 93 | assert_eq!( | ||
| 94 | edit.range, | ||
| 95 | 0..u32::try_from(expected.encode_utf16().count())? | ||
| 96 | ); | ||
| 97 | let operation: u64 = edit.replacement.split_once(':').unwrap().0.parse()?; | ||
| 98 | expected = payload(operation, size); | ||
| 99 | assert_eq!(edit.replacement, expected); | ||
| 100 | operation | ||
| 101 | } | ||
| 102 | onestore_offline::Operation::Insert(insertion) => { | ||
| 103 | assert_eq!(operation_kind, "insert"); | ||
| 104 | let Kind::RichText { text, .. } = | ||
| 105 | &revision.nodes[&insertion.text_object()].kind | ||
| 106 | else { | ||
| 107 | panic!("Missing inserted text") | ||
| 108 | }; | ||
| 109 | let operation: u64 = text.split_once(':').unwrap().0.parse()?; | ||
| 110 | assert_eq!(*text, payload(operation, size)); | ||
| 111 | assert_eq!(serde_json::to_value(&insertion)?["text"], *text); | ||
| 112 | let outline = &revision.nodes[&insertion.object()]; | ||
| 113 | assert!(matches!(outline.kind, Kind::Outline { .. })); | ||
| 114 | assert_eq!( | ||
| 115 | (outline.layout.x, outline.layout.y), | ||
| 116 | (Some(144.0), Some(operation as f32 * 72.0)) | ||
| 117 | ); | ||
| 118 | let (_, page) = document | ||
| 119 | .pages()? | ||
| 120 | .into_iter() | ||
| 121 | .find(|(space, _)| *space == sid) | ||
| 122 | .unwrap(); | ||
| 123 | assert!(revision.nodes[&page].children.contains(&insertion.object())); | ||
| 124 | operation | ||
| 125 | } | ||
| 126 | onestore_offline::Operation::Format(edit) => { | ||
| 127 | assert_eq!(operation_kind, "format"); | ||
| 128 | assert_eq!(edit.object, target); | ||
| 129 | assert_eq!(edit.before, expected); | ||
| 130 | assert_eq!( | ||
| 131 | edit.range, | ||
| 132 | 0..u32::try_from(expected.encode_utf16().count())? | ||
| 133 | ); | ||
| 134 | let [TextAttribute::FontSize(value)] = edit.attributes.as_slice() else { | ||
| 135 | panic!("Unexpected formatting intent") | ||
| 136 | }; | ||
| 137 | assert!((7.0..=130.0).contains(value) && value.fract() == 0.0); | ||
| 138 | font_size = Some(*value); | ||
| 139 | *value as u64 - 6 | ||
| 140 | } | ||
| 141 | onestore_offline::Operation::Split(_) | ||
| 142 | | onestore_offline::Operation::CreatePage(_) | ||
| 143 | | onestore_offline::Operation::Pages(_) | ||
| 144 | | onestore_offline::Operation::Join(_) | ||
| 145 | | onestore_offline::Operation::Outline(_) | ||
| 146 | | onestore_offline::Operation::Tree(_) => { | ||
| 147 | panic!("Unexpected operation for this fixture") | ||
| 148 | } | ||
| 149 | }; | ||
| 150 | assert!(operations.last().is_none_or(|last| *last < operation)); | ||
| 151 | assert!(ids.last().is_none_or(|last| *last < pending.id)); | ||
| 152 | operations.push(operation); | ||
| 153 | ids.push(pending.id); | ||
| 154 | } | ||
| 155 | assert!(matches!(&revision.nodes[&target].kind,Kind::RichText{text,..} if *text==expected)); | ||
| 156 | if let Some(font_size) = font_size { | ||
| 157 | assert!( | ||
| 158 | revision | ||
| 159 | .text_runs(target)? | ||
| 160 | .iter() | ||
| 161 | .all(|run| run.format.font_size == Some(font_size)) | ||
| 162 | ); | ||
| 163 | } | ||
| 164 | if operations.is_empty() { | ||
| 165 | assert!( | ||
| 166 | snapshot == base, | ||
| 167 | "An empty local queue changed its working image" | ||
| 168 | ); | ||
| 169 | } | ||
| 170 | if let Some(output) = args.get(3) { | ||
| 171 | std::fs::write(output, &snapshot)?; | ||
| 172 | } | ||
| 173 | println!( | ||
| 174 | "{}", | ||
| 175 | serde_json::json!({"operations": operations, "ids": ids, "section_bytes": snapshot.len(), "complete_payloads": true}) | ||
| 176 | ); | ||
| 177 | return Ok(()); | ||
| 178 | } | ||
| 179 | assert_eq!(mode, "edit"); | ||
| 180 | assert!( | ||
| 181 | matches!(Replica::open(path), Err(onestore_offline::Error::Database(error)) if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy)) | ||
| 182 | ); | ||
| 183 | println!("ready"); | ||
| 184 | io::stdout().flush()?; | ||
| 185 | let input = io::stdin(); | ||
| 186 | let mut lines = input.lock().lines(); | ||
| 187 | let instruction = lines.next().unwrap()?; | ||
| 188 | let (operation, acknowledgement) = instruction.split_once(' ').unwrap(); | ||
| 189 | let operation: u64 = operation.parse()?; | ||
| 190 | let source = cache.snapshot()?; | ||
| 191 | let (sid, oid, text) = content(&source); | ||
| 192 | let replacement = payload(operation, size); | ||
| 193 | println!("editing {operation}"); | ||
| 194 | io::stdout().flush()?; | ||
| 195 | let id = match operation_kind.as_str() { | ||
| 196 | "text" => cache.edit_text( | ||
| 197 | &source, | ||
| 198 | sid, | ||
| 199 | oid, | ||
| 200 | 0..text.encode_utf16().count().try_into()?, | ||
| 201 | &replacement, | ||
| 202 | )?, | ||
| 203 | "insert" => { | ||
| 204 | let store = Store::parse(&source)?; | ||
| 205 | let index = RevisionIndex::parse(&store)?; | ||
| 206 | let document = Document::parse(&index)?; | ||
| 207 | let (sid, page) = document.pages()?[0]; | ||
| 208 | let insertion = Insertion::outline( | ||
| 209 | page, | ||
| 210 | 144.0, | ||
| 211 | operation as f32 * 72.0, | ||
| 212 | &replacement, | ||
| 213 | "Fixture", | ||
| 214 | )?; | ||
| 215 | cache.insert(&source, sid, &insertion)? | ||
| 216 | } | ||
| 217 | "format" => { | ||
| 218 | assert!((1..=124).contains(&operation)); | ||
| 219 | cache.format( | ||
| 220 | &source, | ||
| 221 | sid, | ||
| 222 | oid, | ||
| 223 | 0..text.encode_utf16().count().try_into()?, | ||
| 224 | &[TextAttribute::FontSize(6.0 + operation as f32)], | ||
| 225 | )? | ||
| 226 | } | ||
| 227 | _ => unreachable!(), | ||
| 228 | } | ||
| 229 | .unwrap(); | ||
| 230 | if acknowledgement == "unack" { | ||
| 231 | println!("durable {operation} {id}"); | ||
| 232 | } else { | ||
| 233 | println!("ack {operation} {id}"); | ||
| 234 | } | ||
| 235 | io::stdout().flush()?; | ||
| 236 | lines.next().transpose()?; | ||
| 237 | Ok(()) | ||
| 238 | } | ||
crates/onestore-offline/examples/recovery_probe.rs deleted-160| ... | @@ -1,160 +0,0 @@ | ||
| 1 | mod support { | ||
| 2 | pub mod view; | ||
| 3 | } | ||
| 4 | use support::view::view; | ||
| 5 | |||
| 6 | use onestore::{CommitError, CommitIo, PreparedEdit}; | ||
| 7 | use onestore_offline::{EditStatus, Remote, Replica}; | ||
| 8 | use serde_json::json; | ||
| 9 | use std::{ | ||
| 10 | env, | ||
| 11 | fs::{self, File, OpenOptions}, | ||
| 12 | io::{self, Write}, | ||
| 13 | os::unix::fs::FileExt, | ||
| 14 | path::Path, | ||
| 15 | }; | ||
| 16 | |||
| 17 | fn phase(name: &str) { | ||
| 18 | println!("{}", json!({"event":"phase", "name":name})); | ||
| 19 | io::stdout().flush().unwrap(); | ||
| 20 | if env::var("ONESTORE_RECOVERY_PAUSE").ok().as_deref() == Some(name) { | ||
| 21 | let mut line = String::new(); | ||
| 22 | assert!( | ||
| 23 | io::stdin().read_line(&mut line).unwrap() > 0, | ||
| 24 | "Controller closed a paused operation" | ||
| 25 | ); | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | struct Disk { | ||
| 30 | file: File, | ||
| 31 | writes: usize, | ||
| 32 | flushes: usize, | ||
| 33 | } | ||
| 34 | |||
| 35 | impl CommitIo for Disk { | ||
| 36 | fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> { | ||
| 37 | self.file.read_at(output, offset) | ||
| 38 | } | ||
| 39 | fn write_at(&mut self, offset: u64, data: &[u8]) -> io::Result<usize> { | ||
| 40 | self.writes += 1; | ||
| 41 | println!( | ||
| 42 | "{}", | ||
| 43 | json!({"event":"write", "number":self.writes, "offset":offset, "bytes":data.len()}) | ||
| 44 | ); | ||
| 45 | phase(&format!("write-{}-before", self.writes)); | ||
| 46 | let result = self.file.write_at(data, offset); | ||
| 47 | phase(&format!("write-{}-after", self.writes)); | ||
| 48 | result | ||
| 49 | } | ||
| 50 | fn flush(&mut self) -> io::Result<()> { | ||
| 51 | self.flushes += 1; | ||
| 52 | phase(&format!("flush-{}-before", self.flushes)); | ||
| 53 | let result = self.file.sync_all(); | ||
| 54 | phase(&format!("flush-{}-after", self.flushes)); | ||
| 55 | result | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | impl Remote for Disk { | ||
| 60 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 61 | phase("read-before"); | ||
| 62 | let result = onestore::read_snapshot( | ||
| 63 | |offset, output| self.file.read_at(output, offset), | ||
| 64 | 256 * 1024 * 1024, | ||
| 65 | ) | ||
| 66 | .and_then(|snapshot| snapshot.ok_or_else(|| io::ErrorKind::WouldBlock.into())); | ||
| 67 | phase("read-after"); | ||
| 68 | result | ||
| 69 | } | ||
| 70 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 71 | phase("publish-before"); | ||
| 72 | let result = edit.commit(self); | ||
| 73 | phase("publish-after"); | ||
| 74 | result | ||
| 75 | } | ||
| 76 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 77 | phase("confirm-before"); | ||
| 78 | let result = onestore::confirm_snapshot(self, snapshot); | ||
| 79 | phase("confirm-after"); | ||
| 80 | result | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | fn report(cache: &Replica, root: &Path) -> Result<(), Box<dyn std::error::Error>> { | ||
| 85 | let local = view(&cache.snapshot()?)?; | ||
| 86 | let remote = view(&fs::read(root.join("remote.one"))?)?; | ||
| 87 | let (status, revision) = match cache.status(1)? { | ||
| 88 | Some(EditStatus::Pending) => ("pending", None), | ||
| 89 | Some(EditStatus::AwaitingConfirmation { revision }) => { | ||
| 90 | ("uncertain", Some(revision.to_string())) | ||
| 91 | } | ||
| 92 | Some(EditStatus::Published { revision }) => ("published", Some(revision.to_string())), | ||
| 93 | Some(EditStatus::Conflict(_)) => ("conflict", None), | ||
| 94 | None => ("missing", None), | ||
| 95 | }; | ||
| 96 | println!( | ||
| 97 | "{}", | ||
| 98 | json!({"event":"state", "status":status, "revision":revision, | ||
| 99 | "local_text":local.text, "remote_text":remote.text, "remote_revision":remote.revision.to_string(), | ||
| 100 | "pending":cache.pending()?.iter().map(|pending|match &pending.operation { | ||
| 101 | onestore_offline::Operation::Text(edit) => json!({"id":pending.id,"before":edit.before,"replacement":edit.replacement,"range":[edit.range.start,edit.range.end]}), | ||
| 102 | operation => json!({"id":pending.id,"operation":operation}), | ||
| 103 | }).collect::<Vec<_>>() }) | ||
| 104 | ); | ||
| 105 | Ok(()) | ||
| 106 | } | ||
| 107 | |||
| 108 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 109 | let args: Vec<_> = env::args().skip(1).collect(); | ||
| 110 | let mode = args | ||
| 111 | .first() | ||
| 112 | .ok_or("Expected init|inspect|sync DIRECTORY [SOURCE]")?; | ||
| 113 | let root = Path::new(args.get(1).ok_or("Missing owned directory")?); | ||
| 114 | if mode == "init" && args.len() == 3 { | ||
| 115 | let source = fs::read(&args[2])?; | ||
| 116 | let target = view(&source)?; | ||
| 117 | fs::create_dir(root)?; | ||
| 118 | let mut remote = OpenOptions::new() | ||
| 119 | .write(true) | ||
| 120 | .create_new(true) | ||
| 121 | .open(root.join("remote.one"))?; | ||
| 122 | remote.write_all(&source)?; | ||
| 123 | remote.sync_all()?; | ||
| 124 | drop(remote); | ||
| 125 | let cache = Replica::create(root.join("cache.sqlite"), &source)?; | ||
| 126 | let at = u32::try_from(target.text.encode_utf16().count())?; | ||
| 127 | assert_eq!( | ||
| 128 | cache.edit_text( | ||
| 129 | &source, | ||
| 130 | target.space, | ||
| 131 | target.object, | ||
| 132 | at..at, | ||
| 133 | " [offline-recovery]" | ||
| 134 | )?, | ||
| 135 | Some(1) | ||
| 136 | ); | ||
| 137 | phase("local-after"); | ||
| 138 | report(&cache, root)?; | ||
| 139 | } else if args.len() == 2 && ["inspect", "sync"].contains(&mode.as_str()) { | ||
| 140 | let cache = Replica::open(root.join("cache.sqlite"))?; | ||
| 141 | if mode == "sync" { | ||
| 142 | let mut disk = Disk { | ||
| 143 | file: OpenOptions::new() | ||
| 144 | .read(true) | ||
| 145 | .write(true) | ||
| 146 | .open(root.join("remote.one"))?, | ||
| 147 | writes: 0, | ||
| 148 | flushes: 0, | ||
| 149 | }; | ||
| 150 | phase("sync-before"); | ||
| 151 | let result = cache.sync_once(&mut disk); | ||
| 152 | phase("sync-after"); | ||
| 153 | result?; | ||
| 154 | } | ||
| 155 | report(&cache, root)?; | ||
| 156 | } else { | ||
| 157 | return Err("Expected init|inspect|sync DIRECTORY [SOURCE]".into()); | ||
| 158 | } | ||
| 159 | Ok(()) | ||
| 160 | } | ||
crates/onestore-offline/examples/smb_offline_client.rs deleted-863| ... | @@ -1,863 +0,0 @@ | ||
| 1 | #[path = "../../onestore/examples/support/concurrent.rs"] | ||
| 2 | mod concurrent; | ||
| 3 | |||
| 4 | use onestore::{ | ||
| 5 | CommitError, CommitState, ExGuid, Insertion, ParagraphJoin, ParagraphSplit, PreparedEdit, | ||
| 6 | RevisionIndex, Store, TextAttribute, TreeEdit, document::Document, | ||
| 7 | }; | ||
| 8 | use onestore_offline::{EditStatus, Error, Remote, Replica, SmbRemote}; | ||
| 9 | use onestore_smb::{Client, Credentials}; | ||
| 10 | use serde_json::json; | ||
| 11 | use std::{ | ||
| 12 | env, | ||
| 13 | io::{self, Write}, | ||
| 14 | path::{Path, PathBuf}, | ||
| 15 | sync::Arc, | ||
| 16 | thread, | ||
| 17 | time::{Duration, Instant, SystemTime, UNIX_EPOCH}, | ||
| 18 | }; | ||
| 19 | |||
| 20 | mod support { | ||
| 21 | pub mod view; | ||
| 22 | } | ||
| 23 | use concurrent::{DocumentView, document_view}; | ||
| 24 | use support::view::view; | ||
| 25 | |||
| 26 | impl DocumentView { | ||
| 27 | fn changes(&self, before: &Self) -> Self { | ||
| 28 | let difference = | ||
| 29 | |old: &serde_json::Map<String, serde_json::Value>, | ||
| 30 | new: &serde_json::Map<String, serde_json::Value>| { | ||
| 31 | old.keys() | ||
| 32 | .chain(new.keys()) | ||
| 33 | .filter(|id| old.get(*id) != new.get(*id)) | ||
| 34 | .map(|id| (id.clone(), new.get(id).cloned().unwrap_or_default())) | ||
| 35 | .collect() | ||
| 36 | }; | ||
| 37 | Self { | ||
| 38 | texts: difference(&before.texts, &self.texts), | ||
| 39 | graph: difference(&before.graph, &self.graph), | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | const DOCUMENT_OPERATIONS: [&str; 10] = [ | ||
| 45 | "insert", | ||
| 46 | "format", | ||
| 47 | "text", | ||
| 48 | "split", | ||
| 49 | "right_text", | ||
| 50 | "nest", | ||
| 51 | "unnest", | ||
| 52 | "join", | ||
| 53 | "tail_split", | ||
| 54 | "delete", | ||
| 55 | ]; | ||
| 56 | |||
| 57 | fn now() -> u128 { | ||
| 58 | SystemTime::now() | ||
| 59 | .duration_since(UNIX_EPOCH) | ||
| 60 | .unwrap() | ||
| 61 | .as_micros() | ||
| 62 | } | ||
| 63 | |||
| 64 | #[derive(Clone)] | ||
| 65 | enum Pause { | ||
| 66 | Outage(PathBuf), | ||
| 67 | FormatReply(PathBuf), | ||
| 68 | } | ||
| 69 | |||
| 70 | struct Traced<R> { | ||
| 71 | remote: R, | ||
| 72 | before: Option<(String, Option<DocumentView>)>, | ||
| 73 | pause: Option<Pause>, | ||
| 74 | documents: bool, | ||
| 75 | } | ||
| 76 | |||
| 77 | impl<R: Remote> Remote for Traced<R> { | ||
| 78 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 79 | let started = now(); | ||
| 80 | let bytes = self.remote.read()?; | ||
| 81 | let observed = view(&bytes).map_err(|error| io::Error::other(error.to_string()))?; | ||
| 82 | let documents = if self.documents { | ||
| 83 | Some(document_view(&bytes).map_err(io::Error::other)?) | ||
| 84 | } else { | ||
| 85 | None | ||
| 86 | }; | ||
| 87 | println!( | ||
| 88 | "{}", | ||
| 89 | json!({"event":"read", "started_us":started, "finished_us":now(), "text":observed.text, "documents":documents.as_ref().map(|view| &view.texts), "document_graph":documents.as_ref().map(|view| &view.graph)}) | ||
| 90 | ); | ||
| 91 | self.before = Some((observed.text, documents)); | ||
| 92 | Ok(bytes) | ||
| 93 | } | ||
| 94 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 95 | let after = view(edit.as_bytes()).map_err(|error| CommitError { | ||
| 96 | state: CommitState::NotCommitted, | ||
| 97 | error: io::Error::other(error.to_string()), | ||
| 98 | })?; | ||
| 99 | let documents = if self.documents { | ||
| 100 | Some(document_view(edit.as_bytes()).map_err(|error| CommitError { | ||
| 101 | state: CommitState::NotCommitted, | ||
| 102 | error: io::Error::other(error), | ||
| 103 | })?) | ||
| 104 | } else { | ||
| 105 | None | ||
| 106 | }; | ||
| 107 | let changes = if let Some(after) = &documents { | ||
| 108 | let before = self | ||
| 109 | .before | ||
| 110 | .as_ref() | ||
| 111 | .and_then(|(_, documents)| documents.as_ref()) | ||
| 112 | .ok_or_else(|| CommitError { | ||
| 113 | state: CommitState::NotCommitted, | ||
| 114 | error: io::Error::other("Document publication has no observed source"), | ||
| 115 | })?; | ||
| 116 | Some(after.changes(before)) | ||
| 117 | } else { | ||
| 118 | None | ||
| 119 | }; | ||
| 120 | let pause = match &self.pause { | ||
| 121 | Some(Pause::Outage(marker)) => Some(( | ||
| 122 | marker, | ||
| 123 | marker.parent().unwrap().join("offline-outage-resumed"), | ||
| 124 | "outage", | ||
| 125 | )), | ||
| 126 | Some(Pause::FormatReply(marker)) | ||
| 127 | if changes.as_ref().is_some_and(|changes| { | ||
| 128 | changes.texts.len() == 1 | ||
| 129 | && self | ||
| 130 | .before | ||
| 131 | .as_ref() | ||
| 132 | .and_then(|(_, before)| before.as_ref()) | ||
| 133 | .is_some_and(|before| { | ||
| 134 | changes.texts.keys().all(|id| before.texts.contains_key(id)) | ||
| 135 | }) | ||
| 136 | }) => | ||
| 137 | { | ||
| 138 | Some((marker, marker.with_extension("resume"), "format")) | ||
| 139 | } | ||
| 140 | _ => None, | ||
| 141 | }; | ||
| 142 | if let Some((marker, resumed, kind)) = pause | ||
| 143 | && !resumed.exists() | ||
| 144 | { | ||
| 145 | std::fs::write(marker, after.revision.to_string()).map_err(|error| CommitError { | ||
| 146 | state: CommitState::NotCommitted, | ||
| 147 | error, | ||
| 148 | })?; | ||
| 149 | println!( | ||
| 150 | "{}", | ||
| 151 | json!({"event":"publication_paused", "revision":after.revision.to_string(), "kind":kind, "at_us":now()}) | ||
| 152 | ); | ||
| 153 | let deadline = Instant::now() + Duration::from_secs(120); | ||
| 154 | while !resumed.exists() { | ||
| 155 | if Instant::now() >= deadline { | ||
| 156 | return Err(CommitError { | ||
| 157 | state: CommitState::NotCommitted, | ||
| 158 | error: io::Error::new( | ||
| 159 | io::ErrorKind::TimedOut, | ||
| 160 | "Offline publication barrier timed out", | ||
| 161 | ), | ||
| 162 | }); | ||
| 163 | } | ||
| 164 | thread::sleep(Duration::from_millis(10)); | ||
| 165 | } | ||
| 166 | } | ||
| 167 | let started = now(); | ||
| 168 | let result = self.remote.publish(edit); | ||
| 169 | let finished = now(); | ||
| 170 | println!( | ||
| 171 | "{}", | ||
| 172 | json!({"event":"remote_attempt", "started_us":started, "finished_us":finished, | ||
| 173 | "revision":after.revision.to_string(), "space":after.space.to_string(), "object":after.object.to_string(), "before":self.before.as_ref().map(|(text,_)|text), "after":after.text, | ||
| 174 | "state":format!("{:?}", result.as_ref().map_or_else(|error| error.state, |_| CommitState::Committed)), | ||
| 175 | "documents":documents.as_ref().map(|view| &view.texts), "document_graph":documents.as_ref().map(|view| &view.graph), | ||
| 176 | "document_changes":changes.as_ref().map(|view| &view.texts), "document_graph_changes":changes.as_ref().map(|view| &view.graph)}) | ||
| 177 | ); | ||
| 178 | if result | ||
| 179 | .as_ref() | ||
| 180 | .is_err_and(|error| error.state == CommitState::Unknown) | ||
| 181 | && let Some(Pause::FormatReply(marker)) = &self.pause | ||
| 182 | && marker.with_extension("isolate").exists() | ||
| 183 | { | ||
| 184 | std::fs::write(marker.with_extension("isolate"), serde_json::to_vec(&json!({"space":after.space.to_string(), "revision":after.revision.to_string(), "after_us":finished})).unwrap()) | ||
| 185 | .map_err(|error| CommitError { state:CommitState::Unknown, error })?; | ||
| 186 | println!( | ||
| 187 | "{}", | ||
| 188 | json!({"event":"confirmation_paused", "revision":after.revision.to_string(), "at_us":now()}) | ||
| 189 | ); | ||
| 190 | let deadline = Instant::now() + Duration::from_secs(120); | ||
| 191 | while !marker.with_extension("confirmation-resume").exists() { | ||
| 192 | if Instant::now() >= deadline { | ||
| 193 | return Err(CommitError { | ||
| 194 | state: CommitState::Unknown, | ||
| 195 | error: io::Error::new( | ||
| 196 | io::ErrorKind::TimedOut, | ||
| 197 | "Offline confirmation barrier timed out", | ||
| 198 | ), | ||
| 199 | }); | ||
| 200 | } | ||
| 201 | thread::sleep(Duration::from_millis(10)); | ||
| 202 | } | ||
| 203 | } | ||
| 204 | result | ||
| 205 | } | ||
| 206 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 207 | let captured = (|| -> Result<_, Box<dyn std::error::Error>> { | ||
| 208 | let store = Store::parse(snapshot)?; | ||
| 209 | let index = RevisionIndex::parse(&store)?; | ||
| 210 | let revisions = index | ||
| 211 | .spaces | ||
| 212 | .iter() | ||
| 213 | .map(|(id, space)| { | ||
| 214 | ( | ||
| 215 | id.to_string(), | ||
| 216 | space | ||
| 217 | .revisions | ||
| 218 | .keys() | ||
| 219 | .map(ToString::to_string) | ||
| 220 | .collect::<Vec<_>>(), | ||
| 221 | ) | ||
| 222 | }) | ||
| 223 | .collect::<std::collections::BTreeMap<_, _>>(); | ||
| 224 | let current = index | ||
| 225 | .spaces | ||
| 226 | .iter() | ||
| 227 | .filter_map(|(id, space)| { | ||
| 228 | space | ||
| 229 | .labels | ||
| 230 | .get(&(ExGuid::default(), 1)) | ||
| 231 | .map(|revision| (id.to_string(), revision.to_string())) | ||
| 232 | }) | ||
| 233 | .collect::<std::collections::BTreeMap<_, _>>(); | ||
| 234 | let path = env::var_os("ONESTORE_OFFLINE_CONFIRM_DIR").map(|directory| { | ||
| 235 | PathBuf::from(directory).join(format!("{}-{}.one", std::process::id(), now())) | ||
| 236 | }); | ||
| 237 | if let Some(path) = &path { | ||
| 238 | std::fs::OpenOptions::new() | ||
| 239 | .write(true) | ||
| 240 | .create_new(true) | ||
| 241 | .open(path)? | ||
| 242 | .write_all(snapshot)?; | ||
| 243 | } | ||
| 244 | Ok((revisions, current, path)) | ||
| 245 | })(); | ||
| 246 | let (revisions, current, capture) = captured.map_err(|error| CommitError { | ||
| 247 | state: CommitState::NotCommitted, | ||
| 248 | error: io::Error::other(error.to_string()), | ||
| 249 | })?; | ||
| 250 | let started = now(); | ||
| 251 | let result = self.remote.confirm(snapshot); | ||
| 252 | println!( | ||
| 253 | "{}", | ||
| 254 | json!({"event":"remote_confirm", "started_us":started, "finished_us":now(), "revisions":revisions, "current_revisions":current, "capture":capture.as_ref().and_then(|path| path.file_name()).map(|name| name.to_string_lossy()), "text":self.before.as_ref().map(|(text,_)|text), | ||
| 255 | "state":format!("{:?}", result.as_ref().map_or_else(|error| error.state, |_| CommitState::Committed)), "error":result.as_ref().err().map(|error|error.error.to_string())}) | ||
| 256 | ); | ||
| 257 | result | ||
| 258 | } | ||
| 259 | } | ||
| 260 | |||
| 261 | fn tokens(text: &str) -> Option<Vec<&str>> { | ||
| 262 | let mut remaining = text.strip_prefix("Concurrent edits:")?; | ||
| 263 | let mut tokens = Vec::new(); | ||
| 264 | while !remaining.is_empty() { | ||
| 265 | let body = remaining.strip_prefix(" [w")?; | ||
| 266 | let (token, tail) = body.split_once(']')?; | ||
| 267 | let (actor, operation) = token.split_once(':')?; | ||
| 268 | if actor.is_empty() | ||
| 269 | || operation.is_empty() | ||
| 270 | || !actor | ||
| 271 | .bytes() | ||
| 272 | .chain(operation.bytes()) | ||
| 273 | .all(|b| b.is_ascii_digit()) | ||
| 274 | || tokens.contains(&token) | ||
| 275 | { | ||
| 276 | return None; | ||
| 277 | } | ||
| 278 | tokens.push(token); | ||
| 279 | remaining = tail; | ||
| 280 | } | ||
| 281 | Some(tokens) | ||
| 282 | } | ||
| 283 | |||
| 284 | // This owned workload explicitly resolves append conflicts after all retained tokens. | ||
| 285 | fn append_position(before: &str, current: &str, token: &str) -> Option<u32> { | ||
| 286 | let original = tokens(before)?; | ||
| 287 | let present = tokens(current)?; | ||
| 288 | let mut retained = present.iter(); | ||
| 289 | for token in original { | ||
| 290 | retained.find(|&&candidate| candidate == token)?; | ||
| 291 | } | ||
| 292 | if current.contains(token) { | ||
| 293 | return None; | ||
| 294 | } | ||
| 295 | u32::try_from(current.encode_utf16().count()).ok() | ||
| 296 | } | ||
| 297 | |||
| 298 | fn queue_document( | ||
| 299 | cache: &Replica, | ||
| 300 | actor: &str, | ||
| 301 | operation: usize, | ||
| 302 | parent: Option<ExGuid>, | ||
| 303 | deadline: Instant, | ||
| 304 | ) -> Result<(ExGuid, [u64; DOCUMENT_OPERATIONS.len()]), Box<dyn std::error::Error>> { | ||
| 305 | let source = cache.snapshot()?; | ||
| 306 | let store = Store::parse(&source)?; | ||
| 307 | let index = RevisionIndex::parse(&store)?; | ||
| 308 | let document = Document::parse(&index)?; | ||
| 309 | let (space, page) = document.pages()?[0]; | ||
| 310 | let text = format!("Document {actor}:{operation} 🦀"); | ||
| 311 | let insertion = if operation.is_multiple_of(2) { | ||
| 312 | let column: u32 = actor | ||
| 313 | .strip_prefix('w') | ||
| 314 | .ok_or("Missing writer number")? | ||
| 315 | .parse()?; | ||
| 316 | Insertion::outline( | ||
| 317 | page, | ||
| 318 | 144.0 + column as f32 * 240.0, | ||
| 319 | 144.0 + operation as f32 * 72.0, | ||
| 320 | &text, | ||
| 321 | "Offline document writer", | ||
| 322 | )? | ||
| 323 | } else { | ||
| 324 | Insertion::paragraph( | ||
| 325 | parent.ok_or("Missing prior outline")?, | ||
| 326 | None, | ||
| 327 | &text, | ||
| 328 | "Offline document writer", | ||
| 329 | )? | ||
| 330 | } | ||
| 331 | .with_formatting( | ||
| 332 | 0..u32::try_from(text.encode_utf16().count())?, | ||
| 333 | &[ | ||
| 334 | TextAttribute::FontSize(13.5), | ||
| 335 | TextAttribute::Color(Some([0x44, 0x55, 0x66])), | ||
| 336 | ], | ||
| 337 | )?; | ||
| 338 | let parent = if operation.is_multiple_of(2) { | ||
| 339 | insertion.object() | ||
| 340 | } else { | ||
| 341 | parent.unwrap() | ||
| 342 | }; | ||
| 343 | let end = u32::try_from(text.encode_utf16().count())?; | ||
| 344 | let split = ParagraphSplit::new(insertion.text_object(), end - 2, "Offline document writer")?; | ||
| 345 | let tail_split = | ||
| 346 | ParagraphSplit::new(insertion.text_object(), end + 1, "Offline document writer")?; | ||
| 347 | let join = ParagraphJoin::new( | ||
| 348 | insertion.text_object(), | ||
| 349 | split.text_object(), | ||
| 350 | "Offline document writer", | ||
| 351 | )?; | ||
| 352 | let attributes = [ | ||
| 353 | TextAttribute::Bold(true), | ||
| 354 | TextAttribute::FontSize(18.0 + (operation % 9) as f32), | ||
| 355 | TextAttribute::Color(Some([0x12, 0x34, 0x56])), | ||
| 356 | ]; | ||
| 357 | let mut ids = [0; DOCUMENT_OPERATIONS.len()]; | ||
| 358 | for (step, id) in ids.iter_mut().enumerate() { | ||
| 359 | let kind = DOCUMENT_OPERATIONS[step]; | ||
| 360 | let tree = match kind { | ||
| 361 | "nest" => { | ||
| 362 | let source = cache.snapshot()?; | ||
| 363 | let store = Store::parse(&source)?; | ||
| 364 | let index = RevisionIndex::parse(&store)?; | ||
| 365 | let document = Document::parse(&index)?; | ||
| 366 | let section = &document.spaces[&space]; | ||
| 367 | let view = &section.revisions[&section.contexts[&ExGuid::default()]]; | ||
| 368 | let paragraph = view | ||
| 369 | .nodes | ||
| 370 | .iter() | ||
| 371 | .find(|(_, node)| node.content == [insertion.text_object()]) | ||
| 372 | .map(|(id, _)| *id) | ||
| 373 | .ok_or("Missing inserted paragraph")?; | ||
| 374 | Some(TreeEdit::move_to( | ||
| 375 | split.object(), | ||
| 376 | paragraph, | ||
| 377 | None, | ||
| 378 | "Offline document writer", | ||
| 379 | )?) | ||
| 380 | } | ||
| 381 | "unnest" => Some(TreeEdit::move_to( | ||
| 382 | split.object(), | ||
| 383 | parent, | ||
| 384 | None, | ||
| 385 | "Offline document writer", | ||
| 386 | )?), | ||
| 387 | "delete" => Some(TreeEdit::delete( | ||
| 388 | tail_split.object(), | ||
| 389 | "Offline document writer", | ||
| 390 | )?), | ||
| 391 | _ => None, | ||
| 392 | }; | ||
| 393 | let range = match kind { | ||
| 394 | "text" => end - 3..end, | ||
| 395 | "split" => end - 2..end - 2, | ||
| 396 | "tail_split" => end + 1..end + 1, | ||
| 397 | "right_text" => 0..2, | ||
| 398 | _ => 1..end - 2, | ||
| 399 | }; | ||
| 400 | let target = if kind == "right_text" { | ||
| 401 | split.text_object() | ||
| 402 | } else if kind == "delete" { | ||
| 403 | tail_split.text_object() | ||
| 404 | } else { | ||
| 405 | insertion.text_object() | ||
| 406 | }; | ||
| 407 | let replacement = match kind { | ||
| 408 | "text" => Some(" e\u{301}🐈"), | ||
| 409 | "right_text" => Some("B🦋"), | ||
| 410 | _ => None, | ||
| 411 | }; | ||
| 412 | loop { | ||
| 413 | if Instant::now() >= deadline { | ||
| 414 | return Err("Document queue timed out; cache retained".into()); | ||
| 415 | } | ||
| 416 | let source = cache.snapshot()?; | ||
| 417 | let started = now(); | ||
| 418 | let result = match kind { | ||
| 419 | "insert" => cache.insert(&source, space, &insertion), | ||
| 420 | "format" => cache.format(&source, space, target, range.clone(), &attributes), | ||
| 421 | "text" | "right_text" => { | ||
| 422 | cache.edit_text(&source, space, target, range.clone(), replacement.unwrap()) | ||
| 423 | } | ||
| 424 | "split" => cache.split(&source, space, &split), | ||
| 425 | "tail_split" => cache.split(&source, space, &tail_split), | ||
| 426 | "join" => cache.join(&source, space, &join), | ||
| 427 | "nest" | "unnest" | "delete" => cache.tree(&source, space, tree.as_ref().unwrap()), | ||
| 428 | _ => unreachable!(), | ||
| 429 | }; | ||
| 430 | match result { | ||
| 431 | Ok(Some(acknowledged)) => { | ||
| 432 | *id = acknowledged; | ||
| 433 | println!( | ||
| 434 | "{}", | ||
| 435 | json!({"event":"local_document_commit","id":acknowledged,"operation":operation,"kind":kind, | ||
| 436 | "space":space.to_string(),"object":target.to_string(),"document":insertion.text_object().to_string(), | ||
| 437 | "text":text,"insertion":if kind=="insert" {Some(&insertion)} else {None}, | ||
| 438 | "split":match kind { "split" => Some(&split), "tail_split" => Some(&tail_split), _ => None }, | ||
| 439 | "tree":tree, | ||
| 440 | "joined":if kind=="join" {Some(join.texts().map(|id| id.to_string()))} else {None}, | ||
| 441 | "range":[range.start,range.end],"attributes":attributes,"replacement":replacement, | ||
| 442 | "started_us":started,"finished_us":now()}) | ||
| 443 | ); | ||
| 444 | break; | ||
| 445 | } | ||
| 446 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {} | ||
| 447 | other => return Err(format!("Unexpected document queue result: {other:?}").into()), | ||
| 448 | } | ||
| 449 | } | ||
| 450 | } | ||
| 451 | Ok((parent, ids)) | ||
| 452 | } | ||
| 453 | |||
| 454 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 455 | let args: Vec<_> = env::args().skip(1).collect(); | ||
| 456 | if args.len() != 7 | ||
| 457 | || !["read", "write"].contains(&args[0].as_str()) | ||
| 458 | || !args[2].bytes().all(|b| b.is_ascii_alphanumeric()) | ||
| 459 | { | ||
| 460 | return Err("Expected read|write FILE ACTOR OPERATIONS START_FILE STOP_FILE SEED".into()); | ||
| 461 | } | ||
| 462 | let documents = env::var_os("ONESTORE_OFFLINE_DOCUMENTS").is_some(); | ||
| 463 | let address = env::var("ONESTORE_SMB_LAB")?; | ||
| 464 | let share = env::var("ONESTORE_SMB_SHARE")?; | ||
| 465 | let initial = Client::connect( | ||
| 466 | &address, | ||
| 467 | &share, | ||
| 468 | Credentials::default(), | ||
| 469 | Duration::from_secs(5), | ||
| 470 | )?; | ||
| 471 | if args[0] == "read" { | ||
| 472 | return concurrent::run( | ||
| 473 | &args, | ||
| 474 | |path| initial.read(path, 256 * 1024 * 1024), | ||
| 475 | |_, _, _, _, _, _| unreachable!(), | ||
| 476 | ); | ||
| 477 | } | ||
| 478 | let timeout: u64 = env::var("ONESTORE_CLIENT_TIMEOUT_MS") | ||
| 479 | .unwrap_or_else(|_| "600000".into()) | ||
| 480 | .parse()?; | ||
| 481 | let deadline = Instant::now() | ||
| 482 | .checked_add(Duration::from_millis(timeout)) | ||
| 483 | .ok_or("Invalid timeout")?; | ||
| 484 | let operations: usize = args[3].parse()?; | ||
| 485 | let mut seed: u64 = args[6].parse()?; | ||
| 486 | if operations == 0 { | ||
| 487 | return Err("Expected positive operations".into()); | ||
| 488 | } | ||
| 489 | let source = initial.read(&args[1], 256 * 1024 * 1024)?; | ||
| 490 | drop(initial); | ||
| 491 | let cache_path = Path::new(&args[4]) | ||
| 492 | .parent() | ||
| 493 | .ok_or("Missing workload directory")? | ||
| 494 | .join(format!("{}.sqlite", args[2])); | ||
| 495 | let cache = Arc::new(Replica::create(&cache_path, &source)?); | ||
| 496 | println!( | ||
| 497 | "{}", | ||
| 498 | json!({"event":"ready", "pid":std::process::id(), "actor":args[2], "offline":true, "document_operations":documents,"document_graph":documents,"document_kinds":if documents {DOCUMENT_OPERATIONS.as_slice()} else {&[]}}) | ||
| 499 | ); | ||
| 500 | while !Path::new(&args[4]).exists() { | ||
| 501 | if Instant::now() >= deadline { | ||
| 502 | return Err("Start barrier timed out".into()); | ||
| 503 | } | ||
| 504 | thread::sleep(Duration::from_millis(5)); | ||
| 505 | } | ||
| 506 | let path = args[1].clone(); | ||
| 507 | let outage = env::var_os("ONESTORE_OFFLINE_OUTAGE_DIR").map(PathBuf::from); | ||
| 508 | let pause = outage | ||
| 509 | .as_ref() | ||
| 510 | .map(|directory| Pause::Outage(directory.join(format!("offline-paused-{}", args[2])))) | ||
| 511 | .or_else(|| { | ||
| 512 | env::var_os("ONESTORE_OFFLINE_FORMAT_REPLY_DIR").map(|directory| { | ||
| 513 | Pause::FormatReply( | ||
| 514 | PathBuf::from(directory).join(format!("offline-paused-{}", args[2])), | ||
| 515 | ) | ||
| 516 | }) | ||
| 517 | }); | ||
| 518 | let (fatal_tx, fatal_rx) = std::sync::mpsc::channel(); | ||
| 519 | let worker = cache.start_sync(Duration::from_millis(50), move || { | ||
| 520 | let client = Client::connect(&address, &share, Credentials::default(), Duration::from_secs(5))?; | ||
| 521 | println!("{}", json!({"event":"transport_connected", "at_us":now()})); | ||
| 522 | Ok(Traced { remote: SmbRemote::new(client, &path, 256 * 1024 * 1024), before:None, pause:pause.clone(), documents }) | ||
| 523 | }, move |result| { | ||
| 524 | if let Err(error) = result { | ||
| 525 | println!("{}", json!({"event":"sync_error", "error":error.to_string(), "at_us":now()})); | ||
| 526 | if !matches!(error, Error::RemoteIo(_) | Error::Remote(_)) && !matches!(error, Error::Io(error) if error.kind() == io::ErrorKind::WouldBlock) { | ||
| 527 | let _ = fatal_tx.send(error.to_string()); | ||
| 528 | } | ||
| 529 | } | ||
| 530 | })?; | ||
| 531 | let mut ids = Vec::new(); | ||
| 532 | let mut document_ids = std::collections::BTreeSet::new(); | ||
| 533 | let mut document_parent = None; | ||
| 534 | let result = (|| -> Result<(), Box<dyn std::error::Error>> { | ||
| 535 | let mut generated = 0; | ||
| 536 | let mut received = 0; | ||
| 537 | loop { | ||
| 538 | match fatal_rx.try_recv() { | ||
| 539 | Ok(error) => return Err(error.into()), | ||
| 540 | Err(std::sync::mpsc::TryRecvError::Disconnected) => { | ||
| 541 | return Err("Worker exited before completion".into()); | ||
| 542 | } | ||
| 543 | Err(std::sync::mpsc::TryRecvError::Empty) => {} | ||
| 544 | } | ||
| 545 | while received < ids.len() { | ||
| 546 | match cache.status(ids[received])? { | ||
| 547 | Some(EditStatus::Published { revision }) => { | ||
| 548 | println!( | ||
| 549 | "{}", | ||
| 550 | json!({"event":if document_ids.contains(&ids[received]) {"document_receipt"} else {"remote_receipt"}, "id":ids[received], "revision":revision.to_string(), "at_us":now()}) | ||
| 551 | ); | ||
| 552 | received += 1; | ||
| 553 | } | ||
| 554 | Some(_) => break, | ||
| 555 | None => return Err("Local intent disappeared".into()), | ||
| 556 | } | ||
| 557 | } | ||
| 558 | if Instant::now() >= deadline { | ||
| 559 | return Err("Offline workload timed out; cache retained".into()); | ||
| 560 | } | ||
| 561 | let pending = cache.pending()?; | ||
| 562 | let capacity = if outage | ||
| 563 | .as_ref() | ||
| 564 | .is_some_and(|directory| !directory.join("offline-outage-down").exists()) | ||
| 565 | { | ||
| 566 | 1 | ||
| 567 | } else if documents && outage.is_some() { | ||
| 568 | 8 * (1 + DOCUMENT_OPERATIONS.len()) | ||
| 569 | } else { | ||
| 570 | 8 | ||
| 571 | }; | ||
| 572 | if generated < operations && pending.len() < capacity { | ||
| 573 | let source = cache.snapshot()?; | ||
| 574 | let target = view(&source)?; | ||
| 575 | let at = u32::try_from(target.text.encode_utf16().count())?; | ||
| 576 | let token = format!(" [{}:{}]", args[2], generated); | ||
| 577 | let started = now(); | ||
| 578 | match cache.edit_text(&source, target.space, target.object, at..at, &token) { | ||
| 579 | Ok(Some(id)) => { | ||
| 580 | println!( | ||
| 581 | "{}", | ||
| 582 | json!({"event":"local_commit", "id":id, "operation":generated, "space":target.space.to_string(), "object":target.object.to_string(), "before":target.text, "token":token, "started_us":started, "finished_us":now()}) | ||
| 583 | ); | ||
| 584 | ids.push(id); | ||
| 585 | if documents { | ||
| 586 | let (parent, added) = queue_document( | ||
| 587 | &cache, | ||
| 588 | &args[2], | ||
| 589 | generated, | ||
| 590 | document_parent, | ||
| 591 | deadline, | ||
| 592 | )?; | ||
| 593 | document_parent = Some(parent); | ||
| 594 | document_ids.extend(added); | ||
| 595 | ids.extend(added); | ||
| 596 | } | ||
| 597 | generated += 1; | ||
| 598 | } | ||
| 599 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {} | ||
| 600 | other => return Err(format!("Unexpected local result: {other:?}").into()), | ||
| 601 | } | ||
| 602 | } | ||
| 603 | if let Some(intent) = pending.first() | ||
| 604 | && matches!(cache.status(intent.id)?, Some(EditStatus::Conflict(_))) | ||
| 605 | { | ||
| 606 | let local = cache.snapshot()?; | ||
| 607 | let remote = cache.remote_snapshot()?; | ||
| 608 | let current = view(&remote)?; | ||
| 609 | let onestore_offline::Operation::Text(edit) = &intent.operation else { | ||
| 610 | return Err(format!( | ||
| 611 | "Document intent {} requires review: {:?}", | ||
| 612 | intent.id, | ||
| 613 | cache.status(intent.id)? | ||
| 614 | ) | ||
| 615 | .into()); | ||
| 616 | }; | ||
| 617 | let at = append_position(&edit.before, &current.text, &edit.replacement) | ||
| 618 | .ok_or("Append model disagrees with retained history")?; | ||
| 619 | match cache.rebase_conflict(intent.id, &local, &remote, at..at) { | ||
| 620 | Ok(()) => println!( | ||
| 621 | "{}", | ||
| 622 | json!({"event":"reviewed_append", "id":intent.id, "before":edit.before, "remote":current.text, "token":edit.replacement, "at_us":now()}) | ||
| 623 | ), | ||
| 624 | Err(Error::Io(error)) | ||
| 625 | if [ | ||
| 626 | io::ErrorKind::ResourceBusy, | ||
| 627 | io::ErrorKind::WouldBlock, | ||
| 628 | io::ErrorKind::InvalidInput, | ||
| 629 | ] | ||
| 630 | .contains(&error.kind()) => {} | ||
| 631 | Err(error) => return Err(error.into()), | ||
| 632 | } | ||
| 633 | } | ||
| 634 | if generated == operations && received == ids.len() { | ||
| 635 | if !cache.pending()?.is_empty() { | ||
| 636 | return Err("Acknowledged queue did not drain".into()); | ||
| 637 | } | ||
| 638 | break; | ||
| 639 | } | ||
| 640 | seed = seed | ||
| 641 | .wrapping_mul(6364136223846793005) | ||
| 642 | .wrapping_add(1442695040888963407); | ||
| 643 | thread::sleep(Duration::from_millis(1 + (seed >> 32) % 7)); | ||
| 644 | } | ||
| 645 | Ok(()) | ||
| 646 | })(); | ||
| 647 | let stopped = worker.stop(); | ||
| 648 | result?; | ||
| 649 | stopped?; | ||
| 650 | drop(cache); | ||
| 651 | let reopened = Replica::open(&cache_path)?; | ||
| 652 | if !reopened.pending()?.is_empty() { | ||
| 653 | return Err("Pending edits reappeared after reopen".into()); | ||
| 654 | } | ||
| 655 | for id in ids { | ||
| 656 | let Some(EditStatus::Published { revision }) = reopened.status(id)? else { | ||
| 657 | return Err("Receipt did not survive reopen".into()); | ||
| 658 | }; | ||
| 659 | println!( | ||
| 660 | "{}", | ||
| 661 | json!({"event":if document_ids.contains(&id) {"reopened_document_receipt"} else {"reopened_receipt"}, "id":id, "revision":revision.to_string()}) | ||
| 662 | ); | ||
| 663 | } | ||
| 664 | println!( | ||
| 665 | "{}", | ||
| 666 | json!({"event":"done", "operations":operations, "at_us":now()}) | ||
| 667 | ); | ||
| 668 | Ok(()) | ||
| 669 | } | ||
| 670 | |||
| 671 | #[test] | ||
| 672 | fn append_review_requires_a_unique_ordered_history_and_an_absent_new_token() { | ||
| 673 | assert_eq!( | ||
| 674 | append_position( | ||
| 675 | "Concurrent edits: [w0:0]", | ||
| 676 | "Concurrent edits: [w1:0] [w0:0]", | ||
| 677 | " [w0:1]" | ||
| 678 | ), | ||
| 679 | Some(31) | ||
| 680 | ); | ||
| 681 | for current in [ | ||
| 682 | "Concurrent edits:", | ||
| 683 | "Concurrent edits: [w0:0] [w0:0]", | ||
| 684 | "Concurrent edits: [w0:1] [w0:0]", | ||
| 685 | "Concurrent edits: changed [w0:0]", | ||
| 686 | ] { | ||
| 687 | assert_eq!( | ||
| 688 | append_position("Concurrent edits: [w0:0]", current, " [w0:1]"), | ||
| 689 | None | ||
| 690 | ); | ||
| 691 | } | ||
| 692 | assert_eq!( | ||
| 693 | append_position( | ||
| 694 | "Concurrent edits: [w0:0] [w1:0]", | ||
| 695 | "Concurrent edits: [w1:0] [w0:0]", | ||
| 696 | " [w0:1]" | ||
| 697 | ), | ||
| 698 | None | ||
| 699 | ); | ||
| 700 | } | ||
| 701 | |||
| 702 | #[cfg(test)] | ||
| 703 | #[path = "../../onestore/tests/support/disk.rs"] | ||
| 704 | mod disk; | ||
| 705 | |||
| 706 | #[cfg(test)] | ||
| 707 | mod tests { | ||
| 708 | use super::*; | ||
| 709 | |||
| 710 | #[test] | ||
| 711 | fn document_workload_retains_dependencies_and_receipts_across_reopen() { | ||
| 712 | struct Server { | ||
| 713 | disk: disk::Disk, | ||
| 714 | lost_reply: bool, | ||
| 715 | } | ||
| 716 | impl Remote for Server { | ||
| 717 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 718 | Ok(self.disk.visible.clone()) | ||
| 719 | } | ||
| 720 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 721 | edit.commit(&mut self.disk)?; | ||
| 722 | if std::mem::take(&mut self.lost_reply) { | ||
| 723 | Err(CommitError { | ||
| 724 | state: CommitState::Unknown, | ||
| 725 | error: io::Error::from(io::ErrorKind::ConnectionAborted), | ||
| 726 | }) | ||
| 727 | } else { | ||
| 728 | Ok(()) | ||
| 729 | } | ||
| 730 | } | ||
| 731 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 732 | onestore::confirm_snapshot(&mut self.disk, snapshot) | ||
| 733 | } | ||
| 734 | } | ||
| 735 | let source = | ||
| 736 | onestore::create_section("workload.one", "Concurrent edits:", "Author").unwrap(); | ||
| 737 | let directory = tempfile::tempdir().unwrap(); | ||
| 738 | let path = directory.path().join("cache.sqlite"); | ||
| 739 | let mut cache = Replica::create(&path, &source).unwrap(); | ||
| 740 | let mut remote = Traced { | ||
| 741 | remote: Server { | ||
| 742 | disk: disk::Disk { | ||
| 743 | visible: source.clone(), | ||
| 744 | durable: source.clone(), | ||
| 745 | operation: 0, | ||
| 746 | fail_at: None, | ||
| 747 | write_limit: 97, | ||
| 748 | random: 1, | ||
| 749 | }, | ||
| 750 | lost_reply: false, | ||
| 751 | }, | ||
| 752 | before: None, | ||
| 753 | pause: None, | ||
| 754 | documents: true, | ||
| 755 | }; | ||
| 756 | println!( | ||
| 757 | "{}", | ||
| 758 | json!({"event":"ready", "actor":"w0", "offline":true, | ||
| 759 | "document_operations":true, "document_graph":true, "document_kinds":DOCUMENT_OPERATIONS}) | ||
| 760 | ); | ||
| 761 | let deadline = Instant::now() + Duration::from_secs(30); | ||
| 762 | let mut parent = None; | ||
| 763 | let mut ids = Vec::new(); | ||
| 764 | for operation in 0..2 { | ||
| 765 | let (outline, added) = | ||
| 766 | queue_document(&cache, "w0", operation, parent, deadline).unwrap(); | ||
| 767 | parent = Some(outline); | ||
| 768 | ids.extend(added); | ||
| 769 | drop(cache); | ||
| 770 | cache = Replica::open(&path).unwrap(); | ||
| 771 | } | ||
| 772 | let local = cache.snapshot().unwrap(); | ||
| 773 | assert_eq!( | ||
| 774 | cache.pending().unwrap().len(), | ||
| 775 | 2 * DOCUMENT_OPERATIONS.len() | ||
| 776 | ); | ||
| 777 | for (step, id) in ids.iter().enumerate() { | ||
| 778 | remote.remote.lost_reply = matches!( | ||
| 779 | DOCUMENT_OPERATIONS[step % DOCUMENT_OPERATIONS.len()], | ||
| 780 | "split" | "nest" | "unnest" | "join" | "tail_split" | "delete" | ||
| 781 | ); | ||
| 782 | let result = cache.sync_once(&mut remote); | ||
| 783 | let state = if result.is_err() { | ||
| 784 | assert!(matches!( | ||
| 785 | result, | ||
| 786 | Err(Error::Remote(CommitError { | ||
| 787 | state: CommitState::Unknown, | ||
| 788 | .. | ||
| 789 | })) | ||
| 790 | )); | ||
| 791 | drop(cache); | ||
| 792 | cache = Replica::open(&path).unwrap(); | ||
| 793 | cache.sync_once(&mut remote).unwrap().unwrap() | ||
| 794 | } else { | ||
| 795 | result.unwrap().unwrap() | ||
| 796 | }; | ||
| 797 | assert_eq!(state.0, *id); | ||
| 798 | let EditStatus::Published { revision } = state.1 else { | ||
| 799 | panic!("{state:?}") | ||
| 800 | }; | ||
| 801 | println!( | ||
| 802 | "{}", | ||
| 803 | json!({"event":"document_receipt", "id":id, "revision":revision.to_string(), "at_us":now()}) | ||
| 804 | ); | ||
| 805 | drop(cache); | ||
| 806 | cache = Replica::open(&path).unwrap(); | ||
| 807 | assert_eq!( | ||
| 808 | cache.status(*id).unwrap(), | ||
| 809 | Some(EditStatus::Published { revision }) | ||
| 810 | ); | ||
| 811 | if step + 1 < ids.len() { | ||
| 812 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 813 | } | ||
| 814 | } | ||
| 815 | assert!(cache.pending().unwrap().is_empty()); | ||
| 816 | for id in ids { | ||
| 817 | let Some(EditStatus::Published { revision }) = cache.status(id).unwrap() else { | ||
| 818 | panic!() | ||
| 819 | }; | ||
| 820 | println!( | ||
| 821 | "{}", | ||
| 822 | json!({"event":"reopened_document_receipt", "id":id, "revision":revision.to_string()}) | ||
| 823 | ); | ||
| 824 | } | ||
| 825 | remote.read().unwrap(); | ||
| 826 | println!("{}", json!({"event":"done"})); | ||
| 827 | } | ||
| 828 | |||
| 829 | #[test] | ||
| 830 | fn publication_differences_retain_removed_text_and_graph_identities() { | ||
| 831 | let before = DocumentView { | ||
| 832 | texts: serde_json::from_value(json!({"left":{"text":"ab"}, "right":{"text":"cd"}, "untouched":{"text":"ef"}})).unwrap(), | ||
| 833 | graph: serde_json::from_value(json!({"outline":{"children":["a","b"]}, "a":{"content":["left"]}, "b":{"content":["right"]}})).unwrap(), | ||
| 834 | }; | ||
| 835 | let after = DocumentView { | ||
| 836 | texts: serde_json::from_value( | ||
| 837 | json!({"left":{"text":"abcd"}, "untouched":{"text":"ef"}}), | ||
| 838 | ) | ||
| 839 | .unwrap(), | ||
| 840 | graph: serde_json::from_value( | ||
| 841 | json!({"outline":{"children":["a"]}, "a":{"content":["left"]}}), | ||
| 842 | ) | ||
| 843 | .unwrap(), | ||
| 844 | }; | ||
| 845 | let changes = after.changes(&before); | ||
| 846 | assert_eq!( | ||
| 847 | changes.texts, | ||
| 848 | serde_json::from_value::<serde_json::Map<_, _>>( | ||
| 849 | json!({"left":{"text":"abcd"}, "right":null}) | ||
| 850 | ) | ||
| 851 | .unwrap() | ||
| 852 | ); | ||
| 853 | assert_eq!( | ||
| 854 | changes.graph, | ||
| 855 | serde_json::from_value::<serde_json::Map<_, _>>( | ||
| 856 | json!({"outline":{"children":["a"]}, "b":null}) | ||
| 857 | ) | ||
| 858 | .unwrap() | ||
| 859 | ); | ||
| 860 | assert_eq!(before.changes(&after).texts["right"], before.texts["right"]); | ||
| 861 | assert_eq!(before.changes(&before), DocumentView::default()); | ||
| 862 | } | ||
| 863 | } | ||
crates/onestore-offline/examples/support/view.rs deleted-57| ... | @@ -1,57 +0,0 @@ | ||
| 1 | use onestore::{ | ||
| 2 | ExGuid, RevisionIndex, Store, | ||
| 3 | document::{Document, Kind}, | ||
| 4 | }; | ||
| 5 | |||
| 6 | pub struct View { | ||
| 7 | pub space: ExGuid, | ||
| 8 | pub object: ExGuid, | ||
| 9 | pub revision: ExGuid, | ||
| 10 | pub text: String, | ||
| 11 | } | ||
| 12 | |||
| 13 | pub fn view(bytes: &[u8]) -> Result<View, Box<dyn std::error::Error>> { | ||
| 14 | let store = Store::parse(bytes)?; | ||
| 15 | if !store.checksum_mismatches.is_empty() { | ||
| 16 | return Err("Transaction checksum damage".into()); | ||
| 17 | } | ||
| 18 | let index = RevisionIndex::parse(&store)?; | ||
| 19 | index.validate_current()?; | ||
| 20 | let document = Document::parse(&index)?; | ||
| 21 | let mut found = Vec::new(); | ||
| 22 | for (sid, page) in document.pages()? { | ||
| 23 | let space = &document.spaces[&sid]; | ||
| 24 | let rid = space.contexts[&ExGuid::default()]; | ||
| 25 | let revision = &space.revisions[&rid]; | ||
| 26 | let mut pending = vec![page]; | ||
| 27 | let mut seen = std::collections::BTreeSet::new(); | ||
| 28 | while let Some(oid) = pending.pop() { | ||
| 29 | if !seen.insert(oid) { | ||
| 30 | continue; | ||
| 31 | } | ||
| 32 | let node = &revision.nodes[&oid]; | ||
| 33 | pending.extend( | ||
| 34 | node.children | ||
| 35 | .iter() | ||
| 36 | .chain(&node.content) | ||
| 37 | .chain(&node.structure) | ||
| 38 | .copied(), | ||
| 39 | ); | ||
| 40 | if let Kind::RichText { text, .. } = &node.kind | ||
| 41 | && text.starts_with("Concurrent edits:") | ||
| 42 | { | ||
| 43 | revision.text_runs(oid)?; | ||
| 44 | found.push(View { | ||
| 45 | space: sid, | ||
| 46 | object: oid, | ||
| 47 | revision: rid, | ||
| 48 | text: text.clone(), | ||
| 49 | }); | ||
| 50 | } | ||
| 51 | } | ||
| 52 | } | ||
| 53 | if found.len() != 1 { | ||
| 54 | return Err("Expected one concurrent-edit paragraph".into()); | ||
| 55 | } | ||
| 56 | Ok(found.pop().unwrap()) | ||
| 57 | } | ||
crates/onestore-offline/src/assets.rs deleted-134| ... | @@ -1,134 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::FileDataReference; | ||
| 3 | use rusqlite::OptionalExtension; | ||
| 4 | use sha2::{Digest, Sha256}; | ||
| 5 | |||
| 6 | impl Replica { | ||
| 7 | /// Reads a previously downloaded external payload without network access. | ||
| 8 | /// 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>>> { | ||
| 10 | let key = key(filename)?; | ||
| 11 | let connection = self | ||
| 12 | .connection | ||
| 13 | .lock() | ||
| 14 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 15 | cached(&connection, &key, limit) | ||
| 16 | } | ||
| 17 | |||
| 18 | /// Fetches a declared external payload and durably retains it without changing the edit queue. | ||
| 19 | /// Different bytes for an already cached identity return `AssetChanged`, preserving the cache. | ||
| 20 | /// Network I/O does not hold the cache mutex; a stale reference fails before local publication. | ||
| 21 | pub fn fetch_asset( | ||
| 22 | &self, | ||
| 23 | source: &mut impl onestore_notebook::Source, | ||
| 24 | section: &str, | ||
| 25 | filename: &str, | ||
| 26 | limit: usize, | ||
| 27 | ) -> Result<Vec<u8>> { | ||
| 28 | let key = key(filename)?; | ||
| 29 | { | ||
| 30 | let connection = self | ||
| 31 | .connection | ||
| 32 | .lock() | ||
| 33 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 34 | if !referenced(&connection, &key)? { | ||
| 35 | return Err(io::Error::new( | ||
| 36 | io::ErrorKind::InvalidInput, | ||
| 37 | "The retained document images do not reference this external payload", | ||
| 38 | ) | ||
| 39 | .into()); | ||
| 40 | } | ||
| 41 | } | ||
| 42 | let bytes = onestore_notebook::read_external_asset(source, section, filename, limit)?; | ||
| 43 | let mut connection = self | ||
| 44 | .connection | ||
| 45 | .lock() | ||
| 46 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 47 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 48 | if !referenced(&transaction, &key)? { | ||
| 49 | return Err(io::Error::new( | ||
| 50 | io::ErrorKind::ResourceBusy, | ||
| 51 | "The external payload reference changed during download", | ||
| 52 | ) | ||
| 53 | .into()); | ||
| 54 | } | ||
| 55 | let previous = match cached(&transaction, &key, bytes.len()) { | ||
| 56 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::FileTooLarge => { | ||
| 57 | return Err(Error::AssetChanged); | ||
| 58 | } | ||
| 59 | other => other?, | ||
| 60 | }; | ||
| 61 | if let Some(previous) = previous { | ||
| 62 | if previous != bytes { | ||
| 63 | return Err(Error::AssetChanged); | ||
| 64 | } | ||
| 65 | } else { | ||
| 66 | transaction.execute( | ||
| 67 | "INSERT INTO assets(name,data,sha256) VALUES (?1,?2,?3)", | ||
| 68 | params![key, &bytes, &Sha256::digest(&bytes)[..]], | ||
| 69 | )?; | ||
| 70 | } | ||
| 71 | transaction.commit()?; | ||
| 72 | Ok(bytes) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | pub(crate) fn key(filename: &str) -> Result<String> { | ||
| 77 | format!("<file>{filename}").parse::<FileDataReference>()?; | ||
| 78 | Ok(filename.to_ascii_lowercase()) | ||
| 79 | } | ||
| 80 | |||
| 81 | fn referenced(connection: &Connection, key: &str) -> Result<bool> { | ||
| 82 | for column in ["working", "base"] { | ||
| 83 | let image: Vec<u8> = connection.query_row( | ||
| 84 | &format!("SELECT {column} FROM replica WHERE id=1"), | ||
| 85 | [], | ||
| 86 | |row| row.get(0), | ||
| 87 | )?; | ||
| 88 | let store = Store::parse(&image)?; | ||
| 89 | let index = RevisionIndex::parse(&store)?; | ||
| 90 | let document = Document::parse(&index)?; | ||
| 91 | if document.spaces.values().flat_map(|space| space.revisions.values()) | ||
| 92 | .flat_map(|revision| revision.nodes.values()).any(|node| { | ||
| 93 | matches!(&node.kind, Kind::File { reference: FileDataReference::External(name), .. } if name.eq_ignore_ascii_case(key)) | ||
| 94 | }) { | ||
| 95 | return Ok(true); | ||
| 96 | } | ||
| 97 | } | ||
| 98 | Ok(false) | ||
| 99 | } | ||
| 100 | |||
| 101 | pub(crate) fn cached(connection: &Connection, key: &str, limit: usize) -> Result<Option<Vec<u8>>> { | ||
| 102 | let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; | ||
| 103 | if version < 5 { | ||
| 104 | return Ok(None); | ||
| 105 | } | ||
| 106 | let length: Option<i64> = connection | ||
| 107 | .query_row( | ||
| 108 | "SELECT length(data) FROM assets WHERE name=?1", | ||
| 109 | [key], | ||
| 110 | |row| row.get(0), | ||
| 111 | ) | ||
| 112 | .optional()?; | ||
| 113 | let Some(length) = length else { | ||
| 114 | return Ok(None); | ||
| 115 | }; | ||
| 116 | let length = | ||
| 117 | usize::try_from(length).map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?; | ||
| 118 | if length > limit { | ||
| 119 | return Err(io::Error::from(io::ErrorKind::FileTooLarge).into()); | ||
| 120 | } | ||
| 121 | let (bytes, expected): (Vec<u8>, Vec<u8>) = connection.query_row( | ||
| 122 | "SELECT data,sha256 FROM assets WHERE name=?1", | ||
| 123 | [key], | ||
| 124 | |row| Ok((row.get(0)?, row.get(1)?)), | ||
| 125 | )?; | ||
| 126 | if bytes.len() != length || Sha256::digest(&bytes)[..] != expected { | ||
| 127 | return Err(io::Error::new( | ||
| 128 | io::ErrorKind::InvalidData, | ||
| 129 | "Cached external payload checksum mismatch", | ||
| 130 | ) | ||
| 131 | .into()); | ||
| 132 | } | ||
| 133 | Ok(Some(bytes)) | ||
| 134 | } | ||
crates/onestore-offline/src/formatting.rs deleted-203| ... | @@ -1,203 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::TextAttribute; | ||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | use serde_json::{Value, json}; | ||
| 5 | use std::collections::BTreeMap; | ||
| 6 | |||
| 7 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 8 | #[serde(deny_unknown_fields)] | ||
| 9 | struct Span { | ||
| 10 | end: u32, | ||
| 11 | values: Vec<Value>, | ||
| 12 | } | ||
| 13 | |||
| 14 | /// A formatting intent and the text/attribute values observed before local publication. | ||
| 15 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 16 | #[serde(deny_unknown_fields)] | ||
| 17 | pub struct FormatEdit { | ||
| 18 | pub object: ExGuid, | ||
| 19 | pub before: String, | ||
| 20 | pub range: Range<u32>, | ||
| 21 | pub attributes: Vec<TextAttribute>, | ||
| 22 | observed: Vec<Span>, | ||
| 23 | } | ||
| 24 | |||
| 25 | fn fields(attributes: &[TextAttribute]) -> BTreeMap<&'static str, Value> { | ||
| 26 | let mut fields = BTreeMap::new(); | ||
| 27 | for attribute in attributes { | ||
| 28 | let (name, value) = match attribute { | ||
| 29 | TextAttribute::Bold(v) => ("bold", json!(v)), | ||
| 30 | TextAttribute::Italic(v) => ("italic", json!(v)), | ||
| 31 | TextAttribute::Underline(v) => ("underline", json!(v)), | ||
| 32 | TextAttribute::Strike(v) => ("strike", json!(v)), | ||
| 33 | TextAttribute::Superscript(v) => { | ||
| 34 | if *v { | ||
| 35 | fields.insert("subscript", json!(false)); | ||
| 36 | } | ||
| 37 | ("superscript", json!(v)) | ||
| 38 | } | ||
| 39 | TextAttribute::Subscript(v) => { | ||
| 40 | if *v { | ||
| 41 | fields.insert("superscript", json!(false)); | ||
| 42 | } | ||
| 43 | ("subscript", json!(v)) | ||
| 44 | } | ||
| 45 | TextAttribute::Font(v) => ("font", json!(v)), | ||
| 46 | TextAttribute::FontSize(v) => ("font_size", json!(v)), | ||
| 47 | TextAttribute::Color(v) | TextAttribute::Highlight(v) => ( | ||
| 48 | if matches!(attribute, TextAttribute::Color(_)) { | ||
| 49 | "color" | ||
| 50 | } else { | ||
| 51 | "highlight" | ||
| 52 | }, | ||
| 53 | json!(v.map_or(0xff000000, |[r, g, b]| u32::from_le_bytes([r, g, b, 0]))), | ||
| 54 | ), | ||
| 55 | }; | ||
| 56 | fields.insert(name, value); | ||
| 57 | } | ||
| 58 | fields | ||
| 59 | } | ||
| 60 | |||
| 61 | fn observe( | ||
| 62 | source: &[u8], | ||
| 63 | space: ExGuid, | ||
| 64 | object: ExGuid, | ||
| 65 | range: Range<u32>, | ||
| 66 | fields: &BTreeMap<&str, Value>, | ||
| 67 | ) -> Result<Vec<Span>> { | ||
| 68 | let store = Store::parse(source)?; | ||
| 69 | let index = RevisionIndex::parse(&store)?; | ||
| 70 | let document = Document::parse(&index)?; | ||
| 71 | let revision = document.active(space)?; | ||
| 72 | let mut spans: Vec<Span> = Vec::new(); | ||
| 73 | let mut start = 0; | ||
| 74 | for run in revision.text_runs(object)? { | ||
| 75 | let end = | ||
| 76 | start + u32::try_from(run.text.encode_utf16().count()).map_err(io::Error::other)?; | ||
| 77 | if (start < range.end && range.start < end) || (start == 0 && end == 0 && range == (0..0)) { | ||
| 78 | let format = serde_json::to_value(run.format).map_err(io::Error::other)?; | ||
| 79 | let values = fields | ||
| 80 | .iter() | ||
| 81 | .map(|(name, desired)| { | ||
| 82 | let value = &format[*name]; | ||
| 83 | if value.is_null() && desired.is_boolean() { | ||
| 84 | json!(false) | ||
| 85 | } else if value.is_null() && matches!(*name, "color" | "highlight") { | ||
| 86 | json!(0xff000000_u32) | ||
| 87 | } else { | ||
| 88 | value.clone() | ||
| 89 | } | ||
| 90 | }) | ||
| 91 | .collect::<Vec<_>>(); | ||
| 92 | let end = end.min(range.end) - range.start; | ||
| 93 | if let Some(last) = spans.last_mut().filter(|s| s.values == values) { | ||
| 94 | last.end = end; | ||
| 95 | } else { | ||
| 96 | spans.push(Span { end, values }); | ||
| 97 | } | ||
| 98 | } | ||
| 99 | start = end; | ||
| 100 | } | ||
| 101 | Ok(spans) | ||
| 102 | } | ||
| 103 | |||
| 104 | impl Replica { | ||
| 105 | /// Durably records a visual-formatting change and its observed attribute values. | ||
| 106 | /// Independent remote attributes can merge; competing values preserve a conflict. | ||
| 107 | pub fn format( | ||
| 108 | &self, | ||
| 109 | source: &[u8], | ||
| 110 | space: ExGuid, | ||
| 111 | object: ExGuid, | ||
| 112 | range: Range<u32>, | ||
| 113 | attributes: &[TextAttribute], | ||
| 114 | ) -> Result<Option<u64>> { | ||
| 115 | let (edit, prepared) = FormatEdit::capture(source, space, object, range, attributes)?; | ||
| 116 | self.record(source, space, Operation::Format(edit), &prepared) | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | impl FormatEdit { | ||
| 121 | pub(crate) fn capture<'a>( | ||
| 122 | source: &'a [u8], | ||
| 123 | space: ExGuid, | ||
| 124 | object: ExGuid, | ||
| 125 | range: Range<u32>, | ||
| 126 | attributes: &[TextAttribute], | ||
| 127 | ) -> Result<(Self, PreparedEdit<'a>)> { | ||
| 128 | let prepared = PreparedEdit::format(source, space, object, range.clone(), attributes)?; | ||
| 129 | let before = paragraph(source, space, object)?.ok_or_else(|| { | ||
| 130 | io::Error::new( | ||
| 131 | io::ErrorKind::InvalidData, | ||
| 132 | "Prepared formatting has no text target", | ||
| 133 | ) | ||
| 134 | })?; | ||
| 135 | let observed = observe(source, space, object, range.clone(), &fields(attributes))?; | ||
| 136 | let edit = Self { | ||
| 137 | object, | ||
| 138 | before, | ||
| 139 | range, | ||
| 140 | attributes: attributes.to_vec(), | ||
| 141 | observed, | ||
| 142 | }; | ||
| 143 | Ok((edit, prepared)) | ||
| 144 | } | ||
| 145 | |||
| 146 | pub(crate) fn prepare<'a>( | ||
| 147 | &self, | ||
| 148 | snapshot: &'a [u8], | ||
| 149 | space: ExGuid, | ||
| 150 | ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> { | ||
| 151 | let Some(text) = paragraph(snapshot, space, self.object)? else { | ||
| 152 | return Ok(Err(ConflictKind::TargetUnavailable)); | ||
| 153 | }; | ||
| 154 | let Some(range) = rebase::rebase(&self.before, &text, self.range.clone()) else { | ||
| 155 | return Ok(Err(ConflictKind::TextChanged)); | ||
| 156 | }; | ||
| 157 | let prepared = match PreparedEdit::format( | ||
| 158 | snapshot, | ||
| 159 | space, | ||
| 160 | self.object, | ||
| 161 | range.clone(), | ||
| 162 | &self.attributes, | ||
| 163 | ) { | ||
| 164 | Ok(prepared) => prepared, | ||
| 165 | Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)), | ||
| 166 | }; | ||
| 167 | let desired = fields(&self.attributes); | ||
| 168 | let observed = observe(snapshot, space, self.object, range.clone(), &desired)?; | ||
| 169 | let wanted: Vec<_> = desired.values().collect(); | ||
| 170 | for spans in [&self.observed, &observed] { | ||
| 171 | if spans.is_empty() | ||
| 172 | || (!range.is_empty() && spans[0].end == 0) | ||
| 173 | || spans.last().unwrap().end != range.end - range.start | ||
| 174 | || spans.windows(2).any(|s| s[0].end >= s[1].end) | ||
| 175 | || spans.iter().any(|s| s.values.len() != wanted.len()) | ||
| 176 | { | ||
| 177 | return Ok(Err(ConflictKind::UnsupportedEdit)); | ||
| 178 | } | ||
| 179 | } | ||
| 180 | let (mut before, mut after) = (0, 0); | ||
| 181 | while before < self.observed.len() && after < observed.len() { | ||
| 182 | let old = &self.observed[before]; | ||
| 183 | let current = &observed[after]; | ||
| 184 | if old | ||
| 185 | .values | ||
| 186 | .iter() | ||
| 187 | .zip(&current.values) | ||
| 188 | .zip(&wanted) | ||
| 189 | .any(|((old, new), wanted)| new != old && new != *wanted) | ||
| 190 | { | ||
| 191 | return Ok(Err(ConflictKind::FormattingChanged)); | ||
| 192 | } | ||
| 193 | let end = old.end.min(current.end); | ||
| 194 | if old.end == end { | ||
| 195 | before += 1; | ||
| 196 | } | ||
| 197 | if current.end == end { | ||
| 198 | after += 1; | ||
| 199 | } | ||
| 200 | } | ||
| 201 | Ok(Ok(prepared)) | ||
| 202 | } | ||
| 203 | } | ||
crates/onestore-offline/src/lib.rs deleted-432| ... | @@ -1,432 +0,0 @@ | ||
| 1 | #![forbid(unsafe_code)] | ||
| 2 | #![doc = include_str!("../README.md")] | ||
| 3 | |||
| 4 | use onestore::{ | ||
| 5 | ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store, | ||
| 6 | document::{Document, Kind}, | ||
| 7 | }; | ||
| 8 | use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; | ||
| 9 | use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration}; | ||
| 10 | |||
| 11 | mod assets; | ||
| 12 | mod formatting; | ||
| 13 | mod outline; | ||
| 14 | mod pages; | ||
| 15 | mod paragraph; | ||
| 16 | mod rebase; | ||
| 17 | mod recovery; | ||
| 18 | mod schema; | ||
| 19 | mod tree; | ||
| 20 | pub use formatting::FormatEdit; | ||
| 21 | pub use outline::OutlineEdit; | ||
| 22 | pub use pages::PageEdits; | ||
| 23 | pub use paragraph::{JoinEdit, SplitEdit}; | ||
| 24 | pub use recovery::{Recovery, RecoverySummary}; | ||
| 25 | pub use tree::TreeEdit; | ||
| 26 | mod sync; | ||
| 27 | pub use sync::{ConflictKind, EditStatus, Remote}; | ||
| 28 | mod worker; | ||
| 29 | pub use worker::SyncWorker; | ||
| 30 | #[cfg(feature = "smb")] | ||
| 31 | mod smb; | ||
| 32 | #[cfg(feature = "smb")] | ||
| 33 | pub use smb::SmbRemote; | ||
| 34 | |||
| 35 | #[derive(Debug, thiserror::Error)] | ||
| 36 | pub enum Error { | ||
| 37 | #[error(transparent)] | ||
| 38 | Database(#[from] rusqlite::Error), | ||
| 39 | #[error(transparent)] | ||
| 40 | Io(#[from] io::Error), | ||
| 41 | #[error(transparent)] | ||
| 42 | Document(#[from] onestore::Error), | ||
| 43 | #[error(transparent)] | ||
| 44 | Remote(#[from] onestore::CommitError), | ||
| 45 | #[error(transparent)] | ||
| 46 | RemoteIo(io::Error), | ||
| 47 | #[error(transparent)] | ||
| 48 | Notebook(#[from] onestore_notebook::Error), | ||
| 49 | #[error("External payload identity now refers to different bytes")] | ||
| 50 | AssetChanged, | ||
| 51 | } | ||
| 52 | |||
| 53 | type Result<T> = std::result::Result<T, Error>; | ||
| 54 | |||
| 55 | const APPLICATION_ID: u32 = 0x4f4e454f; | ||
| 56 | const SCHEMA_VERSION: u32 = 11; | ||
| 57 | |||
| 58 | /// Text and its observed precondition, retained across cache reopen and rebasing. | ||
| 59 | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] | ||
| 60 | #[serde(deny_unknown_fields)] | ||
| 61 | pub struct TextEdit { | ||
| 62 | pub object: ExGuid, | ||
| 63 | pub before: String, | ||
| 64 | pub range: Range<u32>, | ||
| 65 | pub replacement: String, | ||
| 66 | } | ||
| 67 | |||
| 68 | #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] | ||
| 69 | #[serde(deny_unknown_fields)] | ||
| 70 | pub enum Operation { | ||
| 71 | CreatePage(PageCreation), | ||
| 72 | Pages(PageEdits), | ||
| 73 | Text(TextEdit), | ||
| 74 | Insert(Insertion), | ||
| 75 | Format(FormatEdit), | ||
| 76 | Split(SplitEdit), | ||
| 77 | Join(JoinEdit), | ||
| 78 | Outline(OutlineEdit), | ||
| 79 | Tree(TreeEdit), | ||
| 80 | } | ||
| 81 | |||
| 82 | /// A locally acknowledged intent; its ID remains stable across cache reopen. | ||
| 83 | #[derive(Debug, Clone, PartialEq)] | ||
| 84 | pub struct PendingEdit { | ||
| 85 | pub id: u64, | ||
| 86 | pub space: ExGuid, | ||
| 87 | pub operation: Operation, | ||
| 88 | } | ||
| 89 | |||
| 90 | /// Owns one local cache. Share this handle between threads; a second open fails busy. | ||
| 91 | /// SQLite's exclusive connection retains ownership between local transactions. | ||
| 92 | pub struct Replica { | ||
| 93 | connection: Mutex<Connection>, | ||
| 94 | synchronization: Mutex<()>, | ||
| 95 | worker: Mutex<std::sync::Weak<worker::Signal>>, | ||
| 96 | } | ||
| 97 | |||
| 98 | impl Replica { | ||
| 99 | /// Seeds a new cache from a validated notebook image, refusing any existing path. | ||
| 100 | /// An initialization error preserves the created file for inspection. | ||
| 101 | pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> { | ||
| 102 | validate(source)?; | ||
| 103 | let mut options = OpenOptions::new(); | ||
| 104 | options.read(true).write(true).create_new(true); | ||
| 105 | #[cfg(unix)] | ||
| 106 | { | ||
| 107 | use std::os::unix::fs::OpenOptionsExt; | ||
| 108 | options.mode(0o600); | ||
| 109 | } | ||
| 110 | drop(options.open(path.as_ref())?); | ||
| 111 | Self::connect(path.as_ref(), Some(source)) | ||
| 112 | } | ||
| 113 | |||
| 114 | /// Reopens an existing cache and its durable pending edits without network access. | ||
| 115 | /// Unrecognized databases and unsupported journal modes are rejected without conversion. | ||
| 116 | pub fn open(path: impl AsRef<Path>) -> Result<Self> { | ||
| 117 | Self::connect(path.as_ref(), None) | ||
| 118 | } | ||
| 119 | |||
| 120 | fn connect(path: &Path, source: Option<&[u8]>) -> Result<Self> { | ||
| 121 | let mut connection = cache_connection(path)?; | ||
| 122 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?; | ||
| 123 | let application: u32 = | ||
| 124 | transaction.pragma_query_value(None, "application_id", |row| row.get(0))?; | ||
| 125 | let version: u32 = | ||
| 126 | transaction.pragma_query_value(None, "user_version", |row| row.get(0))?; | ||
| 127 | if let Some(source) = source { | ||
| 128 | let tables: i64 = | ||
| 129 | transaction | ||
| 130 | .query_row("SELECT count(*) FROM sqlite_schema", [], |row| row.get(0))?; | ||
| 131 | if application != 0 || version != 0 || tables != 0 { | ||
| 132 | return Err(io::Error::new( | ||
| 133 | io::ErrorKind::AlreadyExists, | ||
| 134 | "Cache initialization found an existing database", | ||
| 135 | ) | ||
| 136 | .into()); | ||
| 137 | } | ||
| 138 | transaction.pragma_update(None, "application_id", APPLICATION_ID)?; | ||
| 139 | transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?; | ||
| 140 | transaction.execute_batch( | ||
| 141 | " | ||
| 142 | CREATE TABLE replica ( | ||
| 143 | id INTEGER PRIMARY KEY CHECK(id=1), | ||
| 144 | base BLOB NOT NULL, | ||
| 145 | working BLOB NOT NULL | ||
| 146 | ) STRICT; | ||
| 147 | ", | ||
| 148 | )?; | ||
| 149 | schema::create(&transaction)?; | ||
| 150 | transaction.execute("INSERT INTO replica VALUES (1, ?1, ?1)", [source])?; | ||
| 151 | } else { | ||
| 152 | if application != APPLICATION_ID || !(1..=SCHEMA_VERSION).contains(&version) { | ||
| 153 | return Err(io::Error::new( | ||
| 154 | io::ErrorKind::InvalidData, | ||
| 155 | "Unrecognized cache or unsupported schema version", | ||
| 156 | ) | ||
| 157 | .into()); | ||
| 158 | } | ||
| 159 | validate_images(&transaction)?; | ||
| 160 | if version < SCHEMA_VERSION { | ||
| 161 | schema::migrate(&transaction, version)?; | ||
| 162 | transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?; | ||
| 163 | } | ||
| 164 | pending(&transaction)?; | ||
| 165 | } | ||
| 166 | transaction.commit()?; | ||
| 167 | Ok(Self { | ||
| 168 | connection: Mutex::new(connection), | ||
| 169 | synchronization: Mutex::new(()), | ||
| 170 | worker: Mutex::new(std::sync::Weak::new()), | ||
| 171 | }) | ||
| 172 | } | ||
| 173 | |||
| 174 | /// Returns the latest complete locally committed image, including pending edits. | ||
| 175 | pub fn snapshot(&self) -> Result<Vec<u8>> { | ||
| 176 | let connection = self | ||
| 177 | .connection | ||
| 178 | .lock() | ||
| 179 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 180 | Ok( | ||
| 181 | connection.query_row("SELECT working FROM replica WHERE id=1", [], |row| { | ||
| 182 | row.get(0) | ||
| 183 | })?, | ||
| 184 | ) | ||
| 185 | } | ||
| 186 | |||
| 187 | pub fn pending(&self) -> Result<Vec<PendingEdit>> { | ||
| 188 | let connection = self | ||
| 189 | .connection | ||
| 190 | .lock() | ||
| 191 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 192 | pending(&connection) | ||
| 193 | } | ||
| 194 | |||
| 195 | /// Atomically records an intent and its resulting local image; returns its durable ID. | ||
| 196 | /// Unchanged text returns `None`. A stale image returns `Io(ResourceBusy)`. | ||
| 197 | /// On synchronization, replacement text inherits the remote style at the rebased start. | ||
| 198 | /// After a database error, reopen and inspect the cache before retrying the edit. | ||
| 199 | pub fn edit_text( | ||
| 200 | &self, | ||
| 201 | source: &[u8], | ||
| 202 | space: ExGuid, | ||
| 203 | object: ExGuid, | ||
| 204 | range: Range<u32>, | ||
| 205 | replacement: &str, | ||
| 206 | ) -> Result<Option<u64>> { | ||
| 207 | let edit = PreparedEdit::text(source, space, object, range.clone(), replacement)?; | ||
| 208 | let before = paragraph(source, space, object)?.ok_or_else(|| { | ||
| 209 | io::Error::new( | ||
| 210 | io::ErrorKind::InvalidData, | ||
| 211 | "Prepared edit has no text target", | ||
| 212 | ) | ||
| 213 | })?; | ||
| 214 | self.record( | ||
| 215 | source, | ||
| 216 | space, | ||
| 217 | Operation::Text(TextEdit { | ||
| 218 | object, | ||
| 219 | before, | ||
| 220 | range, | ||
| 221 | replacement: replacement.to_owned(), | ||
| 222 | }), | ||
| 223 | &edit, | ||
| 224 | ) | ||
| 225 | } | ||
| 226 | |||
| 227 | /// Durably queues a validated insertion with its stable object identities. | ||
| 228 | /// Uses the same snapshot and local-acknowledgement contract as `edit_text`. | ||
| 229 | pub fn insert( | ||
| 230 | &self, | ||
| 231 | source: &[u8], | ||
| 232 | space: ExGuid, | ||
| 233 | insertion: &Insertion, | ||
| 234 | ) -> Result<Option<u64>> { | ||
| 235 | let edit = PreparedEdit::insert(source, space, insertion)?; | ||
| 236 | self.record(source, space, Operation::Insert(insertion.clone()), &edit) | ||
| 237 | } | ||
| 238 | |||
| 239 | /// Queues a new page and its section entry with stable identities for dependent edits. | ||
| 240 | pub fn create_page(&self, source: &[u8], page: &PageCreation) -> Result<Option<u64>> { | ||
| 241 | let edit = PreparedEdit::create_page(source, page)?; | ||
| 242 | self.record( | ||
| 243 | source, | ||
| 244 | page.space(), | ||
| 245 | Operation::CreatePage(page.clone()), | ||
| 246 | &edit, | ||
| 247 | ) | ||
| 248 | } | ||
| 249 | |||
| 250 | fn record( | ||
| 251 | &self, | ||
| 252 | source: &[u8], | ||
| 253 | space: ExGuid, | ||
| 254 | operation: Operation, | ||
| 255 | edit: &PreparedEdit<'_>, | ||
| 256 | ) -> Result<Option<u64>> { | ||
| 257 | let mut connection = self | ||
| 258 | .connection | ||
| 259 | .lock() | ||
| 260 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 261 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 262 | let current: Vec<u8> = | ||
| 263 | transaction.query_row("SELECT working FROM replica WHERE id=1", [], |row| { | ||
| 264 | row.get(0) | ||
| 265 | })?; | ||
| 266 | if current != source { | ||
| 267 | return Err(io::Error::new( | ||
| 268 | io::ErrorKind::ResourceBusy, | ||
| 269 | "The local snapshot changed before this edit", | ||
| 270 | ) | ||
| 271 | .into()); | ||
| 272 | } | ||
| 273 | if edit.as_bytes() == source { | ||
| 274 | return Ok(None); | ||
| 275 | } | ||
| 276 | transaction.execute( | ||
| 277 | "INSERT INTO edits(space, operation) VALUES (?1, ?2)", | ||
| 278 | params![ | ||
| 279 | space.to_string(), | ||
| 280 | serde_json::to_string(&operation).map_err(io::Error::other)? | ||
| 281 | ], | ||
| 282 | )?; | ||
| 283 | let id = u64::try_from(transaction.last_insert_rowid()).map_err(io::Error::other)?; | ||
| 284 | transaction.execute( | ||
| 285 | "UPDATE replica SET working=?1 WHERE id=1", | ||
| 286 | [edit.as_bytes()], | ||
| 287 | )?; | ||
| 288 | transaction.commit()?; | ||
| 289 | drop(connection); | ||
| 290 | self.wake_sync(); | ||
| 291 | Ok(Some(id)) | ||
| 292 | } | ||
| 293 | |||
| 294 | fn wake_sync(&self) { | ||
| 295 | if let Ok(worker) = self.worker.lock() | ||
| 296 | && let Some(worker) = worker.upgrade() | ||
| 297 | { | ||
| 298 | worker.wake(); | ||
| 299 | } | ||
| 300 | } | ||
| 301 | } | ||
| 302 | |||
| 303 | fn active_paths( | ||
| 304 | view: &onestore::document::Revision<'_>, | ||
| 305 | pages: &[ExGuid], | ||
| 306 | objects: &[ExGuid], | ||
| 307 | ) -> Option<Vec<Vec<ExGuid>>> { | ||
| 308 | let parents = view.parents(pages).ok()?; | ||
| 309 | objects | ||
| 310 | .iter() | ||
| 311 | .map(|object| { | ||
| 312 | if !parents.contains_key(object) && !pages.contains(object) { | ||
| 313 | return None; | ||
| 314 | } | ||
| 315 | let mut path = Vec::new(); | ||
| 316 | let mut at = *object; | ||
| 317 | while !pages.contains(&at) { | ||
| 318 | let [parent] = parents.get(&at)?.as_slice() else { | ||
| 319 | return None; | ||
| 320 | }; | ||
| 321 | if path.len() >= view.nodes.len() { | ||
| 322 | return None; | ||
| 323 | } | ||
| 324 | path.push(*parent); | ||
| 325 | at = *parent; | ||
| 326 | } | ||
| 327 | Some(path) | ||
| 328 | }) | ||
| 329 | .collect() | ||
| 330 | } | ||
| 331 | |||
| 332 | fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result<Option<String>> { | ||
| 333 | let store = Store::parse(source)?; | ||
| 334 | let index = RevisionIndex::parse(&store)?; | ||
| 335 | let document = Document::parse(&index)?; | ||
| 336 | let node = document | ||
| 337 | .active(space) | ||
| 338 | .ok() | ||
| 339 | .and_then(|revision| revision.nodes.get(&object)); | ||
| 340 | Ok(match node.map(|node| &node.kind) { | ||
| 341 | Some(Kind::RichText { text, .. }) => Some(text.clone()), | ||
| 342 | _ => None, | ||
| 343 | }) | ||
| 344 | } | ||
| 345 | |||
| 346 | fn validate(source: &[u8]) -> Result<ExGuid> { | ||
| 347 | let store = Store::parse(source)?; | ||
| 348 | if !store.checksum_mismatches.is_empty() { | ||
| 349 | return Err(io::Error::new( | ||
| 350 | io::ErrorKind::InvalidData, | ||
| 351 | "Notebook transaction checksum damage", | ||
| 352 | ) | ||
| 353 | .into()); | ||
| 354 | } | ||
| 355 | let index = RevisionIndex::parse(&store)?; | ||
| 356 | index.validate_current()?; | ||
| 357 | Document::parse(&index)?; | ||
| 358 | Ok(index.root) | ||
| 359 | } | ||
| 360 | |||
| 361 | fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> { | ||
| 362 | let mut query = connection.prepare("SELECT id, space, operation FROM edits ORDER BY id")?; | ||
| 363 | let mut rows = query.query([])?; | ||
| 364 | let mut edits = Vec::new(); | ||
| 365 | while let Some(row) = rows.next()? { | ||
| 366 | let edit = PendingEdit { | ||
| 367 | id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?, | ||
| 368 | space: row.get::<_, String>(1)?.parse()?, | ||
| 369 | operation: serde_json::from_str(&row.get::<_, String>(2)?) | ||
| 370 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?, | ||
| 371 | }; | ||
| 372 | if matches!(&edit.operation, Operation::CreatePage(page) if page.space() != edit.space) { | ||
| 373 | return Err(io::Error::new( | ||
| 374 | io::ErrorKind::InvalidData, | ||
| 375 | "Cached page identity differs from its creation intent", | ||
| 376 | ) | ||
| 377 | .into()); | ||
| 378 | } | ||
| 379 | edits.push(edit); | ||
| 380 | } | ||
| 381 | Ok(edits) | ||
| 382 | } | ||
| 383 | |||
| 384 | fn cache_connection(path: &Path) -> Result<Connection> { | ||
| 385 | let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; | ||
| 386 | connection.busy_timeout(Duration::ZERO)?; | ||
| 387 | connection.execute_batch( | ||
| 388 | "PRAGMA locking_mode=EXCLUSIVE; PRAGMA synchronous=EXTRA; PRAGMA fullfsync=ON; PRAGMA foreign_keys=ON;", | ||
| 389 | )?; | ||
| 390 | for (name, expected) in [("locking_mode", "exclusive"), ("journal_mode", "delete")] { | ||
| 391 | let actual: String = connection.pragma_query_value(None, name, |row| row.get(0))?; | ||
| 392 | if actual != expected { | ||
| 393 | return Err(io::Error::new( | ||
| 394 | io::ErrorKind::InvalidData, | ||
| 395 | "Unsupported cache locking or journal mode", | ||
| 396 | ) | ||
| 397 | .into()); | ||
| 398 | } | ||
| 399 | } | ||
| 400 | for (name, expected) in [("synchronous", 3), ("fullfsync", 1), ("foreign_keys", 1)] { | ||
| 401 | let actual: i64 = connection.pragma_query_value(None, name, |row| row.get(0))?; | ||
| 402 | if actual != expected { | ||
| 403 | return Err(io::Error::new( | ||
| 404 | io::ErrorKind::Unsupported, | ||
| 405 | "Required cache synchronization is unavailable", | ||
| 406 | ) | ||
| 407 | .into()); | ||
| 408 | } | ||
| 409 | } | ||
| 410 | Ok(connection) | ||
| 411 | } | ||
| 412 | |||
| 413 | fn validate_images(connection: &Connection) -> Result<()> { | ||
| 414 | let integrity: String = connection.query_row("PRAGMA quick_check", [], |row| row.get(0))?; | ||
| 415 | if integrity != "ok" { | ||
| 416 | return Err( | ||
| 417 | io::Error::new(io::ErrorKind::InvalidData, "Cache integrity check failed").into(), | ||
| 418 | ); | ||
| 419 | } | ||
| 420 | let (base, working): (Vec<u8>, Vec<u8>) = | ||
| 421 | connection.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| { | ||
| 422 | Ok((row.get(0)?, row.get(1)?)) | ||
| 423 | })?; | ||
| 424 | if validate(&base)? != validate(&working)? { | ||
| 425 | return Err(io::Error::new( | ||
| 426 | io::ErrorKind::InvalidData, | ||
| 427 | "Cache images belong to different documents", | ||
| 428 | ) | ||
| 429 | .into()); | ||
| 430 | } | ||
| 431 | Ok(()) | ||
| 432 | } | ||
crates/onestore-offline/src/outline.rs deleted-138| ... | @@ -1,138 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use serde::{Deserialize, Serialize}; | ||
| 3 | use serde_json::{Value, json}; | ||
| 4 | |||
| 5 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 6 | #[serde(deny_unknown_fields)] | ||
| 7 | struct Observed { | ||
| 8 | path: Vec<ExGuid>, | ||
| 9 | values: Vec<Value>, | ||
| 10 | } | ||
| 11 | |||
| 12 | /// A layout or saved expansion intent with its original ancestry and property values. | ||
| 13 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 14 | #[serde(deny_unknown_fields)] | ||
| 15 | pub struct OutlineEdit { | ||
| 16 | pub object: ExGuid, | ||
| 17 | pub change: onestore::OutlineEdit, | ||
| 18 | observed: Observed, | ||
| 19 | } | ||
| 20 | |||
| 21 | fn observe( | ||
| 22 | source: &[u8], | ||
| 23 | space: ExGuid, | ||
| 24 | object: ExGuid, | ||
| 25 | change: onestore::OutlineEdit, | ||
| 26 | ) -> Result<Option<Observed>> { | ||
| 27 | let store = Store::parse(source)?; | ||
| 28 | let index = RevisionIndex::parse(&store)?; | ||
| 29 | let document = Document::parse(&index)?; | ||
| 30 | let Ok(view) = document.active(space) else { | ||
| 31 | return Ok(None); | ||
| 32 | }; | ||
| 33 | let pages = document.pages_in(space)?; | ||
| 34 | if pages.len() != 1 { | ||
| 35 | return Ok(None); | ||
| 36 | } | ||
| 37 | let Some(mut paths) = active_paths(view, &pages, &[object]) else { | ||
| 38 | return Ok(None); | ||
| 39 | }; | ||
| 40 | let path = paths.pop().unwrap(); | ||
| 41 | let node = &view.nodes[&object]; | ||
| 42 | let values = match (change, &node.kind) { | ||
| 43 | (onestore::OutlineEdit::Position { .. }, Kind::Outline { .. }) => { | ||
| 44 | vec![json!(node.layout.x), json!(node.layout.y)] | ||
| 45 | } | ||
| 46 | (onestore::OutlineEdit::Width { .. }, Kind::Outline { .. }) => { | ||
| 47 | vec![ | ||
| 48 | json!(node.layout.max_width), | ||
| 49 | json!(node.layout.width_set_by_user.unwrap_or(false)), | ||
| 50 | json!(node.layout.reserved_width), | ||
| 51 | ] | ||
| 52 | } | ||
| 53 | (onestore::OutlineEdit::Collapsed(_), Kind::Paragraph { collapse_state, .. }) => { | ||
| 54 | vec![json!(collapse_state.unwrap_or(0))] | ||
| 55 | } | ||
| 56 | _ => return Ok(None), | ||
| 57 | }; | ||
| 58 | Ok(Some(Observed { path, values })) | ||
| 59 | } | ||
| 60 | |||
| 61 | impl Replica { | ||
| 62 | /// Durably records layout or saved expansion changes, preserving independent remote edits. | ||
| 63 | pub fn outline( | ||
| 64 | &self, | ||
| 65 | source: &[u8], | ||
| 66 | space: ExGuid, | ||
| 67 | object: ExGuid, | ||
| 68 | change: onestore::OutlineEdit, | ||
| 69 | ) -> Result<Option<u64>> { | ||
| 70 | let (edit, prepared) = OutlineEdit::capture(source, space, object, change)?; | ||
| 71 | self.record(source, space, Operation::Outline(edit), &prepared) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | impl OutlineEdit { | ||
| 76 | pub(crate) fn capture<'a>( | ||
| 77 | source: &'a [u8], | ||
| 78 | space: ExGuid, | ||
| 79 | object: ExGuid, | ||
| 80 | change: onestore::OutlineEdit, | ||
| 81 | ) -> Result<(Self, PreparedEdit<'a>)> { | ||
| 82 | let prepared = PreparedEdit::outline(source, space, object, change)?; | ||
| 83 | let observed = observe(source, space, object, change)?.ok_or_else(|| { | ||
| 84 | io::Error::new( | ||
| 85 | io::ErrorKind::InvalidData, | ||
| 86 | "Prepared outline edit has no active target", | ||
| 87 | ) | ||
| 88 | })?; | ||
| 89 | Ok(( | ||
| 90 | Self { | ||
| 91 | object, | ||
| 92 | change, | ||
| 93 | observed, | ||
| 94 | }, | ||
| 95 | prepared, | ||
| 96 | )) | ||
| 97 | } | ||
| 98 | |||
| 99 | pub(crate) fn prepare<'a>( | ||
| 100 | &self, | ||
| 101 | snapshot: &'a [u8], | ||
| 102 | space: ExGuid, | ||
| 103 | ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> { | ||
| 104 | let Some(current) = observe(snapshot, space, self.object, self.change)? else { | ||
| 105 | return Ok(Err(ConflictKind::TargetUnavailable)); | ||
| 106 | }; | ||
| 107 | if current.path != self.observed.path { | ||
| 108 | return Ok(Err(ConflictKind::StructureChanged)); | ||
| 109 | } | ||
| 110 | let prepared = match PreparedEdit::outline(snapshot, space, self.object, self.change) { | ||
| 111 | Ok(prepared) => prepared, | ||
| 112 | Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)), | ||
| 113 | }; | ||
| 114 | let wanted = | ||
| 115 | observe(prepared.as_bytes(), space, self.object, self.change)?.ok_or_else(|| { | ||
| 116 | io::Error::new( | ||
| 117 | io::ErrorKind::InvalidData, | ||
| 118 | "Prepared outline edit lost its target", | ||
| 119 | ) | ||
| 120 | })?; | ||
| 121 | if self.observed.values.len() != current.values.len() | ||
| 122 | || wanted.values.len() != current.values.len() | ||
| 123 | { | ||
| 124 | return Ok(Err(ConflictKind::UnsupportedEdit)); | ||
| 125 | } | ||
| 126 | if self | ||
| 127 | .observed | ||
| 128 | .values | ||
| 129 | .iter() | ||
| 130 | .zip(&current.values) | ||
| 131 | .zip(&wanted.values) | ||
| 132 | .any(|((before, current), wanted)| current != before && current != wanted) | ||
| 133 | { | ||
| 134 | return Ok(Err(ConflictKind::LayoutChanged)); | ||
| 135 | } | ||
| 136 | Ok(Ok(prepared)) | ||
| 137 | } | ||
| 138 | } | ||
crates/onestore-offline/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/onestore-offline/src/paragraph.rs deleted-200| ... | @@ -1,200 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{ParagraphJoin, ParagraphSplit}; | ||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | use serde_json::{Value, json}; | ||
| 5 | |||
| 6 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 7 | #[serde(deny_unknown_fields)] | ||
| 8 | struct Observed { | ||
| 9 | text: String, | ||
| 10 | structure: Value, | ||
| 11 | } | ||
| 12 | |||
| 13 | /// A stable split intent and the paragraph state observed before local publication. | ||
| 14 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 15 | #[serde(deny_unknown_fields)] | ||
| 16 | pub struct SplitEdit { | ||
| 17 | pub intent: ParagraphSplit, | ||
| 18 | observed: Observed, | ||
| 19 | } | ||
| 20 | |||
| 21 | /// A join intent and both paragraphs' observed text, placement and tag ownership. | ||
| 22 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 23 | #[serde(deny_unknown_fields)] | ||
| 24 | pub struct JoinEdit { | ||
| 25 | pub intent: ParagraphJoin, | ||
| 26 | observed: [Observed; 2], | ||
| 27 | } | ||
| 28 | |||
| 29 | fn observe(source: &[u8], space: ExGuid, texts: &[ExGuid]) -> Result<Option<Vec<Observed>>> { | ||
| 30 | let store = Store::parse(source)?; | ||
| 31 | let index = RevisionIndex::parse(&store)?; | ||
| 32 | let document = Document::parse(&index)?; | ||
| 33 | let Ok(view) = document.active(space) else { | ||
| 34 | return Ok(None); | ||
| 35 | }; | ||
| 36 | let pages = document.pages_in(space)?; | ||
| 37 | let Some(paths) = active_paths(view, &pages, texts) else { | ||
| 38 | return Ok(None); | ||
| 39 | }; | ||
| 40 | let mut observed = Vec::new(); | ||
| 41 | for (text, path) in texts.iter().zip(paths) { | ||
| 42 | let node = &view.nodes[text]; | ||
| 43 | let Kind::RichText { text: content, .. } = &node.kind else { | ||
| 44 | return Ok(None); | ||
| 45 | }; | ||
| 46 | let Some(paragraph) = path.first().and_then(|id| view.nodes.get(id)) else { | ||
| 47 | return Ok(None); | ||
| 48 | }; | ||
| 49 | let Kind::Paragraph { lists, .. } = &paragraph.kind else { | ||
| 50 | return Ok(None); | ||
| 51 | }; | ||
| 52 | if paragraph.content != [*text] { | ||
| 53 | return Ok(None); | ||
| 54 | } | ||
| 55 | let lists: Vec<_> = lists.iter().map(|id| (*id, &view.nodes[id].kind)).collect(); | ||
| 56 | observed.push(Observed { | ||
| 57 | text: content.clone(), | ||
| 58 | structure: json!({ | ||
| 59 | "path": path, | ||
| 60 | "children": paragraph.children, | ||
| 61 | "child_level": paragraph.child_level, | ||
| 62 | "paragraph": paragraph.kind, | ||
| 63 | "lists": lists, | ||
| 64 | "tags": node.tags, | ||
| 65 | }), | ||
| 66 | }); | ||
| 67 | } | ||
| 68 | Ok(Some(observed)) | ||
| 69 | } | ||
| 70 | |||
| 71 | impl Replica { | ||
| 72 | /// Durably queues a split with stable new identities and observed structural preconditions. | ||
| 73 | pub fn split( | ||
| 74 | &self, | ||
| 75 | source: &[u8], | ||
| 76 | space: ExGuid, | ||
| 77 | intent: &ParagraphSplit, | ||
| 78 | ) -> Result<Option<u64>> { | ||
| 79 | let (operation, prepared) = SplitEdit::capture(source, space, intent)?; | ||
| 80 | self.record(source, space, Operation::Split(operation), &prepared) | ||
| 81 | } | ||
| 82 | |||
| 83 | /// Durably queues a join; changed placement, children or tags require conflict review. | ||
| 84 | pub fn join( | ||
| 85 | &self, | ||
| 86 | source: &[u8], | ||
| 87 | space: ExGuid, | ||
| 88 | intent: &ParagraphJoin, | ||
| 89 | ) -> Result<Option<u64>> { | ||
| 90 | let (operation, prepared) = JoinEdit::capture(source, space, intent)?; | ||
| 91 | self.record(source, space, Operation::Join(operation), &prepared) | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | impl SplitEdit { | ||
| 96 | pub(crate) fn capture<'a>( | ||
| 97 | source: &'a [u8], | ||
| 98 | space: ExGuid, | ||
| 99 | intent: &ParagraphSplit, | ||
| 100 | ) -> Result<(Self, PreparedEdit<'a>)> { | ||
| 101 | let prepared = PreparedEdit::split(source, space, intent)?; | ||
| 102 | let Some(mut observed) = observe(source, space, &[intent.position().0])? else { | ||
| 103 | return Err(io::Error::new( | ||
| 104 | io::ErrorKind::InvalidData, | ||
| 105 | "Select text in an active paragraph", | ||
| 106 | ) | ||
| 107 | .into()); | ||
| 108 | }; | ||
| 109 | Ok(( | ||
| 110 | Self { | ||
| 111 | intent: intent.clone(), | ||
| 112 | observed: observed.remove(0), | ||
| 113 | }, | ||
| 114 | prepared, | ||
| 115 | )) | ||
| 116 | } | ||
| 117 | |||
| 118 | pub(crate) fn prepare<'a>( | ||
| 119 | &self, | ||
| 120 | source: &'a [u8], | ||
| 121 | space: ExGuid, | ||
| 122 | ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> { | ||
| 123 | let (text, offset) = self.intent.position(); | ||
| 124 | let Some(observed) = observe(source, space, &[text])? else { | ||
| 125 | return Ok(Err(ConflictKind::TargetUnavailable)); | ||
| 126 | }; | ||
| 127 | if observed[0].structure != self.observed.structure { | ||
| 128 | return Ok(Err(ConflictKind::StructureChanged)); | ||
| 129 | } | ||
| 130 | let Some(range) = rebase::rebase(&self.observed.text, &observed[0].text, offset..offset) | ||
| 131 | else { | ||
| 132 | return Ok(Err(ConflictKind::TextChanged)); | ||
| 133 | }; | ||
| 134 | Ok( | ||
| 135 | PreparedEdit::split(source, space, &self.intent.reposition(range.start)) | ||
| 136 | .map_err(|_| ConflictKind::UnsupportedEdit), | ||
| 137 | ) | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | impl JoinEdit { | ||
| 142 | pub(crate) fn review(&self, source: &[u8], space: ExGuid) -> Result<Self> { | ||
| 143 | let (reviewed, _) = Self::capture(source, space, &self.intent)?; | ||
| 144 | if reviewed.observed[0].text.is_empty() != self.observed[0].text.is_empty() { | ||
| 145 | return Err(io::Error::new( | ||
| 146 | io::ErrorKind::InvalidInput, | ||
| 147 | "The join would change its surviving text identity", | ||
| 148 | ) | ||
| 149 | .into()); | ||
| 150 | } | ||
| 151 | Ok(reviewed) | ||
| 152 | } | ||
| 153 | |||
| 154 | pub(crate) fn capture<'a>( | ||
| 155 | source: &'a [u8], | ||
| 156 | space: ExGuid, | ||
| 157 | intent: &ParagraphJoin, | ||
| 158 | ) -> Result<(Self, PreparedEdit<'a>)> { | ||
| 159 | let prepared = PreparedEdit::join(source, space, intent)?; | ||
| 160 | let Some(observed) = observe(source, space, &intent.texts())? else { | ||
| 161 | return Err(io::Error::new( | ||
| 162 | io::ErrorKind::InvalidData, | ||
| 163 | "Select text in two active paragraphs", | ||
| 164 | ) | ||
| 165 | .into()); | ||
| 166 | }; | ||
| 167 | Ok(( | ||
| 168 | Self { | ||
| 169 | intent: intent.clone(), | ||
| 170 | observed: observed.try_into().unwrap(), | ||
| 171 | }, | ||
| 172 | prepared, | ||
| 173 | )) | ||
| 174 | } | ||
| 175 | |||
| 176 | pub(crate) fn prepare<'a>( | ||
| 177 | &self, | ||
| 178 | source: &'a [u8], | ||
| 179 | space: ExGuid, | ||
| 180 | ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> { | ||
| 181 | let Some(observed) = observe(source, space, &self.intent.texts())? else { | ||
| 182 | return Ok(Err(ConflictKind::TargetUnavailable)); | ||
| 183 | }; | ||
| 184 | for (i, (old, new)) in self.observed.iter().zip(&observed).enumerate() { | ||
| 185 | if old.structure != new.structure { | ||
| 186 | return Ok(Err(ConflictKind::StructureChanged)); | ||
| 187 | } | ||
| 188 | let at = if i == 0 { | ||
| 189 | u32::try_from(old.text.encode_utf16().count()).map_err(io::Error::other)? | ||
| 190 | } else { | ||
| 191 | 0 | ||
| 192 | }; | ||
| 193 | if rebase::rebase(&old.text, &new.text, at..at).is_none() { | ||
| 194 | return Ok(Err(ConflictKind::TextChanged)); | ||
| 195 | } | ||
| 196 | } | ||
| 197 | Ok(PreparedEdit::join(source, space, &self.intent) | ||
| 198 | .map_err(|_| ConflictKind::UnsupportedEdit)) | ||
| 199 | } | ||
| 200 | } | ||
crates/onestore-offline/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/onestore-offline/src/recovery.rs deleted-190| ... | @@ -1,190 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use rusqlite::backup::{Backup, StepResult}; | ||
| 3 | use std::{collections::BTreeMap, fs::File}; | ||
| 4 | |||
| 5 | const RECOVERY_ID: u32 = 0x4f4e4552; | ||
| 6 | |||
| 7 | /// Counts and image sizes without notebook text, paths, authors or credentials. | ||
| 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] | ||
| 9 | pub struct RecoverySummary { | ||
| 10 | pub queued_edits: u64, | ||
| 11 | pub conflicts: u64, | ||
| 12 | pub uncertain_edits: u64, | ||
| 13 | pub published_receipts: u64, | ||
| 14 | pub working_bytes: u64, | ||
| 15 | pub remote_bytes: u64, | ||
| 16 | pub cached_assets: u64, | ||
| 17 | pub cached_asset_bytes: u64, | ||
| 18 | } | ||
| 19 | |||
| 20 | /// Read-only recovery evidence; it cannot publish or acknowledge an edit. | ||
| 21 | pub struct Recovery { | ||
| 22 | connection: Connection, | ||
| 23 | } | ||
| 24 | |||
| 25 | impl Recovery { | ||
| 26 | /// Opens an exported archive without migration or conversion into a writable replica. | ||
| 27 | pub fn open(path: impl AsRef<Path>) -> Result<Self> { | ||
| 28 | let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; | ||
| 29 | connection.busy_timeout(Duration::ZERO)?; | ||
| 30 | connection.execute_batch("BEGIN")?; | ||
| 31 | let application: u32 = | ||
| 32 | connection.pragma_query_value(None, "application_id", |row| row.get(0))?; | ||
| 33 | let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; | ||
| 34 | if application != RECOVERY_ID || !(4..=SCHEMA_VERSION).contains(&version) { | ||
| 35 | return Err(io::Error::new( | ||
| 36 | io::ErrorKind::InvalidData, | ||
| 37 | "Unrecognized recovery archive or unsupported schema version", | ||
| 38 | ) | ||
| 39 | .into()); | ||
| 40 | } | ||
| 41 | validate_images(&connection)?; | ||
| 42 | for edit in pending(&connection)? { | ||
| 43 | sync::status(&connection, edit.id)?; | ||
| 44 | } | ||
| 45 | receipts(&connection)?; | ||
| 46 | Ok(Self { connection }) | ||
| 47 | } | ||
| 48 | |||
| 49 | pub fn summary(&self) -> Result<RecoverySummary> { | ||
| 50 | summary(&self.connection) | ||
| 51 | } | ||
| 52 | |||
| 53 | pub fn snapshot(&self) -> Result<Vec<u8>> { | ||
| 54 | Ok(self | ||
| 55 | .connection | ||
| 56 | .query_row("SELECT working FROM replica WHERE id=1", [], |row| { | ||
| 57 | row.get(0) | ||
| 58 | })?) | ||
| 59 | } | ||
| 60 | |||
| 61 | pub fn remote_snapshot(&self) -> Result<Vec<u8>> { | ||
| 62 | Ok(self | ||
| 63 | .connection | ||
| 64 | .query_row("SELECT base FROM replica WHERE id=1", [], |row| row.get(0))?) | ||
| 65 | } | ||
| 66 | |||
| 67 | pub fn pending(&self) -> Result<Vec<PendingEdit>> { | ||
| 68 | pending(&self.connection) | ||
| 69 | } | ||
| 70 | |||
| 71 | pub fn status(&self, id: u64) -> Result<Option<EditStatus>> { | ||
| 72 | sync::status(&self.connection, id) | ||
| 73 | } | ||
| 74 | |||
| 75 | pub fn receipts(&self) -> Result<BTreeMap<u64, ExGuid>> { | ||
| 76 | receipts(&self.connection) | ||
| 77 | } | ||
| 78 | |||
| 79 | /// Reads a previously downloaded external payload without accessing its former server. | ||
| 80 | pub fn cached_asset(&self, filename: &str, limit: usize) -> Result<Option<Vec<u8>>> { | ||
| 81 | assets::cached(&self.connection, &assets::key(filename)?, limit) | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | impl Replica { | ||
| 86 | /// Summarizes durable state without exposing notebook content. | ||
| 87 | pub fn recovery_summary(&self) -> Result<RecoverySummary> { | ||
| 88 | let connection = self | ||
| 89 | .connection | ||
| 90 | .lock() | ||
| 91 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 92 | summary(&connection) | ||
| 93 | } | ||
| 94 | |||
| 95 | /// Exports a consistent archive to a new local path without changing the live queue. | ||
| 96 | /// Archives contain notebook content and cannot be opened as writable replicas. | ||
| 97 | /// An error after publication can leave a complete archive at the destination. | ||
| 98 | pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> { | ||
| 99 | let path = path.as_ref(); | ||
| 100 | if path.file_name().is_none() { | ||
| 101 | return Err(io::Error::new( | ||
| 102 | io::ErrorKind::InvalidInput, | ||
| 103 | "Provide a new recovery archive filename", | ||
| 104 | ) | ||
| 105 | .into()); | ||
| 106 | } | ||
| 107 | let parent = path | ||
| 108 | .parent() | ||
| 109 | .filter(|parent| !parent.as_os_str().is_empty()) | ||
| 110 | .unwrap_or_else(|| Path::new(".")); | ||
| 111 | let temporary = tempfile::Builder::new() | ||
| 112 | .prefix(".onestore-recovery-") | ||
| 113 | .tempfile_in(parent)?; | ||
| 114 | let mut destination = cache_connection(temporary.path())?; | ||
| 115 | { | ||
| 116 | let source = self | ||
| 117 | .connection | ||
| 118 | .lock() | ||
| 119 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 120 | let backup = Backup::new(&source, &mut destination)?; | ||
| 121 | if backup.step(-1)? != StepResult::Done { | ||
| 122 | return Err(io::Error::new( | ||
| 123 | io::ErrorKind::WouldBlock, | ||
| 124 | "Recovery export could not acquire the database snapshot", | ||
| 125 | ) | ||
| 126 | .into()); | ||
| 127 | } | ||
| 128 | } | ||
| 129 | destination.pragma_update(None, "application_id", RECOVERY_ID)?; | ||
| 130 | destination.close().map_err(|(_, error)| error)?; | ||
| 131 | temporary.as_file().sync_all()?; | ||
| 132 | temporary | ||
| 133 | .persist_noclobber(path) | ||
| 134 | .map_err(|error| error.error)?; | ||
| 135 | File::open(parent)?.sync_all()?; | ||
| 136 | Ok(()) | ||
| 137 | } | ||
| 138 | } | ||
| 139 | |||
| 140 | fn summary(connection: &Connection) -> Result<RecoverySummary> { | ||
| 141 | let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; | ||
| 142 | let (cached_assets, cached_asset_bytes) = if version < 5 { | ||
| 143 | (0, 0) | ||
| 144 | } else { | ||
| 145 | connection.query_row( | ||
| 146 | "SELECT count(*),coalesce(sum(length(data)),0) FROM assets", | ||
| 147 | [], | ||
| 148 | |row| Ok((unsigned(row, 0)?, unsigned(row, 1)?)), | ||
| 149 | )? | ||
| 150 | }; | ||
| 151 | Ok(connection.query_row( | ||
| 152 | "SELECT (SELECT count(*) FROM edits), (SELECT count(*) FROM conflicts), | ||
| 153 | (SELECT count(*) FROM attempt), (SELECT count(*) FROM receipts), | ||
| 154 | length(working), length(base) FROM replica WHERE id=1", | ||
| 155 | [], | ||
| 156 | |row| { | ||
| 157 | Ok(RecoverySummary { | ||
| 158 | queued_edits: unsigned(row, 0)?, | ||
| 159 | conflicts: unsigned(row, 1)?, | ||
| 160 | uncertain_edits: unsigned(row, 2)?, | ||
| 161 | published_receipts: unsigned(row, 3)?, | ||
| 162 | working_bytes: unsigned(row, 4)?, | ||
| 163 | remote_bytes: unsigned(row, 5)?, | ||
| 164 | cached_assets, | ||
| 165 | cached_asset_bytes, | ||
| 166 | }) | ||
| 167 | }, | ||
| 168 | )?) | ||
| 169 | } | ||
| 170 | |||
| 171 | fn receipts(connection: &Connection) -> Result<BTreeMap<u64, ExGuid>> { | ||
| 172 | let mut statement = | ||
| 173 | connection.prepare("SELECT edit_id, revision FROM receipts ORDER BY edit_id")?; | ||
| 174 | let mut rows = statement.query([])?; | ||
| 175 | let mut receipts = BTreeMap::new(); | ||
| 176 | while let Some(row) = rows.next()? { | ||
| 177 | receipts.insert(unsigned(row, 0)?, row.get::<_, String>(1)?.parse()?); | ||
| 178 | } | ||
| 179 | Ok(receipts) | ||
| 180 | } | ||
| 181 | |||
| 182 | fn unsigned(row: &rusqlite::Row<'_>, column: usize) -> rusqlite::Result<u64> { | ||
| 183 | u64::try_from(row.get::<_, i64>(column)?).map_err(|error| { | ||
| 184 | rusqlite::Error::FromSqlConversionFailure( | ||
| 185 | column, | ||
| 186 | rusqlite::types::Type::Integer, | ||
| 187 | Box::new(error), | ||
| 188 | ) | ||
| 189 | }) | ||
| 190 | } | ||
crates/onestore-offline/src/schema.rs deleted-143| ... | @@ -1,143 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use rusqlite::{OptionalExtension, Transaction}; | ||
| 3 | |||
| 4 | const CONFLICTS: &str = "CREATE TABLE conflicts ( | ||
| 5 | edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, | ||
| 6 | kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 6) | ||
| 7 | ) STRICT;"; | ||
| 8 | |||
| 9 | const ASSETS: &str = "CREATE TABLE assets ( | ||
| 10 | name TEXT PRIMARY KEY NOT NULL, | ||
| 11 | data BLOB NOT NULL, | ||
| 12 | sha256 BLOB NOT NULL CHECK(length(sha256)=32) | ||
| 13 | ) STRICT;"; | ||
| 14 | |||
| 15 | pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> { | ||
| 16 | transaction.execute_batch( | ||
| 17 | "CREATE TABLE edits ( | ||
| 18 | id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), | ||
| 19 | space TEXT NOT NULL, | ||
| 20 | operation TEXT NOT NULL | ||
| 21 | ) STRICT; | ||
| 22 | CREATE TABLE attempt ( | ||
| 23 | id INTEGER PRIMARY KEY CHECK(id=1), | ||
| 24 | edit_id INTEGER NOT NULL UNIQUE REFERENCES edits(id) ON DELETE CASCADE, | ||
| 25 | revisions TEXT NOT NULL | ||
| 26 | ) STRICT; | ||
| 27 | CREATE TABLE receipts ( | ||
| 28 | edit_id INTEGER PRIMARY KEY CHECK(edit_id>0), | ||
| 29 | revision TEXT NOT NULL | ||
| 30 | ) STRICT;", | ||
| 31 | )?; | ||
| 32 | transaction.execute_batch(CONFLICTS)?; | ||
| 33 | transaction.execute_batch(ASSETS)?; | ||
| 34 | Ok(()) | ||
| 35 | } | ||
| 36 | |||
| 37 | pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()> { | ||
| 38 | if version >= 10 { | ||
| 39 | return Ok(()); | ||
| 40 | } | ||
| 41 | if version >= 3 { | ||
| 42 | transaction.execute_batch("ALTER TABLE conflicts RENAME TO old_conflicts;")?; | ||
| 43 | transaction.execute_batch(CONFLICTS)?; | ||
| 44 | transaction.execute_batch( | ||
| 45 | "INSERT INTO conflicts SELECT * FROM old_conflicts; DROP TABLE old_conflicts;", | ||
| 46 | )?; | ||
| 47 | if version < 5 { | ||
| 48 | transaction.execute_batch(ASSETS)?; | ||
| 49 | } | ||
| 50 | transaction.execute_batch("ALTER TABLE attempt RENAME COLUMN revision TO revisions;")?; | ||
| 51 | migrate_attempts(transaction)?; | ||
| 52 | return Ok(()); | ||
| 53 | } | ||
| 54 | |||
| 55 | let mut query = transaction.prepare( | ||
| 56 | "SELECT id, space, object, before_text, start, end, replacement FROM edits ORDER BY id", | ||
| 57 | )?; | ||
| 58 | let mut rows = query.query([])?; | ||
| 59 | let mut edits = Vec::new(); | ||
| 60 | while let Some(row) = rows.next()? { | ||
| 61 | edits.push(PendingEdit { | ||
| 62 | id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?, | ||
| 63 | space: row.get::<_, String>(1)?.parse()?, | ||
| 64 | operation: Operation::Text(TextEdit { | ||
| 65 | object: row.get::<_, String>(2)?.parse()?, | ||
| 66 | before: row.get(3)?, | ||
| 67 | range: row.get(4)?..row.get(5)?, | ||
| 68 | replacement: row.get(6)?, | ||
| 69 | }), | ||
| 70 | }); | ||
| 71 | } | ||
| 72 | drop(rows); | ||
| 73 | drop(query); | ||
| 74 | let sequence: i64 = transaction | ||
| 75 | .query_row( | ||
| 76 | "SELECT seq FROM sqlite_sequence WHERE name='edits'", | ||
| 77 | [], | ||
| 78 | |row| row.get(0), | ||
| 79 | ) | ||
| 80 | .optional()? | ||
| 81 | .unwrap_or(0); | ||
| 82 | let (mut attempts, mut conflicts, mut receipts) = (Vec::new(), Vec::new(), Vec::new()); | ||
| 83 | if version == 2 { | ||
| 84 | let mut query = transaction.prepare("SELECT edit_id, revision FROM attempt")?; | ||
| 85 | attempts = query | ||
| 86 | .query_map([], |row| { | ||
| 87 | Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) | ||
| 88 | })? | ||
| 89 | .collect::<std::result::Result<_, _>>()?; | ||
| 90 | let mut query = transaction.prepare("SELECT edit_id, kind FROM conflicts")?; | ||
| 91 | conflicts = query | ||
| 92 | .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))? | ||
| 93 | .collect::<std::result::Result<_, _>>()?; | ||
| 94 | let mut query = transaction.prepare("SELECT edit_id, revision FROM receipts")?; | ||
| 95 | receipts = query | ||
| 96 | .query_map([], |row| { | ||
| 97 | Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) | ||
| 98 | })? | ||
| 99 | .collect::<std::result::Result<_, _>>()?; | ||
| 100 | transaction | ||
| 101 | .execute_batch("DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts;")?; | ||
| 102 | } | ||
| 103 | transaction.execute_batch("DROP TABLE edits;")?; | ||
| 104 | create(transaction)?; | ||
| 105 | for edit in edits { | ||
| 106 | transaction.execute( | ||
| 107 | "INSERT INTO edits(id,space,operation) VALUES (?1,?2,?3)", | ||
| 108 | params![ | ||
| 109 | i64::try_from(edit.id).map_err(io::Error::other)?, | ||
| 110 | edit.space.to_string(), | ||
| 111 | serde_json::to_string(&edit.operation).map_err(io::Error::other)? | ||
| 112 | ], | ||
| 113 | )?; | ||
| 114 | } | ||
| 115 | // A drained queue must not reuse IDs belonging to existing durable receipts. | ||
| 116 | transaction.execute("DELETE FROM sqlite_sequence WHERE name='edits'", [])?; | ||
| 117 | transaction.execute( | ||
| 118 | "INSERT INTO sqlite_sequence(name,seq) VALUES ('edits',?1)", | ||
| 119 | [sequence], | ||
| 120 | )?; | ||
| 121 | for (id, revision) in attempts { | ||
| 122 | transaction.execute( | ||
| 123 | "INSERT INTO attempt VALUES (1,?1,?2)", | ||
| 124 | params![id, revision], | ||
| 125 | )?; | ||
| 126 | } | ||
| 127 | for (id, kind) in conflicts { | ||
| 128 | transaction.execute("INSERT INTO conflicts VALUES (?1,?2)", params![id, kind])?; | ||
| 129 | } | ||
| 130 | for (id, revision) in receipts { | ||
| 131 | transaction.execute("INSERT INTO receipts VALUES (?1,?2)", params![id, revision])?; | ||
| 132 | } | ||
| 133 | migrate_attempts(transaction)?; | ||
| 134 | Ok(()) | ||
| 135 | } | ||
| 136 | |||
| 137 | fn migrate_attempts(transaction: &Transaction<'_>) -> Result<()> { | ||
| 138 | transaction.execute_batch( | ||
| 139 | "UPDATE attempt SET revisions=json_object( | ||
| 140 | (SELECT space FROM edits WHERE edits.id=attempt.edit_id), revisions);", | ||
| 141 | )?; | ||
| 142 | Ok(()) | ||
| 143 | } | ||
crates/onestore-offline/src/smb.rs deleted-36| ... | @@ -1,36 +0,0 @@ | ||
| 1 | use crate::Remote; | ||
| 2 | use onestore::{CommitError, PreparedEdit}; | ||
| 3 | use onestore_smb::Client; | ||
| 4 | use std::io; | ||
| 5 | |||
| 6 | /// Binds every reconciliation operation to one share-relative file and read limit. | ||
| 7 | /// Connection loss retires the client; reconnect before subsequent sync attempts. | ||
| 8 | pub struct SmbRemote { | ||
| 9 | client: Client, | ||
| 10 | path: String, | ||
| 11 | limit: usize, | ||
| 12 | } | ||
| 13 | |||
| 14 | impl SmbRemote { | ||
| 15 | pub fn new(client: Client, path: impl Into<String>, limit: usize) -> Self { | ||
| 16 | Self { | ||
| 17 | client, | ||
| 18 | path: path.into(), | ||
| 19 | limit, | ||
| 20 | } | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | impl Remote for SmbRemote { | ||
| 25 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 26 | self.client.read(&self.path, self.limit) | ||
| 27 | } | ||
| 28 | |||
| 29 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 30 | self.client.commit_prepared(&self.path, edit) | ||
| 31 | } | ||
| 32 | |||
| 33 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 34 | self.client.confirm_snapshot(&self.path, snapshot) | ||
| 35 | } | ||
| 36 | } | ||
crates/onestore-offline/src/sync.rs deleted-671| ... | @@ -1,671 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{CommitError, CommitState}; | ||
| 3 | use rusqlite::OptionalExtension; | ||
| 4 | use std::{ | ||
| 5 | collections::BTreeMap, | ||
| 6 | sync::{MutexGuard, TryLockError}, | ||
| 7 | }; | ||
| 8 | |||
| 9 | /// A single remote file with fresh reads and native-compatible guarded publication. | ||
| 10 | /// Errors retain publication state; confirmation compares, flushes, and notifies cached readers. | ||
| 11 | pub trait Remote { | ||
| 12 | fn read(&mut self) -> io::Result<Vec<u8>>; | ||
| 13 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> std::result::Result<(), CommitError>; | ||
| 14 | fn confirm(&mut self, snapshot: &[u8]) -> std::result::Result<(), CommitError>; | ||
| 15 | } | ||
| 16 | |||
| 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 18 | #[repr(i64)] | ||
| 19 | pub enum ConflictKind { | ||
| 20 | TextChanged = 0, | ||
| 21 | TargetUnavailable = 1, | ||
| 22 | UnsupportedEdit = 2, | ||
| 23 | FormattingChanged = 3, | ||
| 24 | StructureChanged = 4, | ||
| 25 | LayoutChanged = 5, | ||
| 26 | ContentChanged = 6, | ||
| 27 | } | ||
| 28 | |||
| 29 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 30 | pub enum EditStatus { | ||
| 31 | Pending, | ||
| 32 | /// Retained publication attempt; this revision alone may be insufficient to confirm it. | ||
| 33 | AwaitingConfirmation { | ||
| 34 | revision: ExGuid, | ||
| 35 | }, | ||
| 36 | Conflict(ConflictKind), | ||
| 37 | /// Revision of the intent's space when its complete effect was confirmed. | ||
| 38 | Published { | ||
| 39 | revision: ExGuid, | ||
| 40 | }, | ||
| 41 | } | ||
| 42 | |||
| 43 | impl Replica { | ||
| 44 | /// Returns a durable receipt or the persisted state of a locally acknowledged edit. | ||
| 45 | pub fn status(&self, id: u64) -> Result<Option<EditStatus>> { | ||
| 46 | let connection = self | ||
| 47 | .connection | ||
| 48 | .lock() | ||
| 49 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 50 | status(&connection, id) | ||
| 51 | } | ||
| 52 | |||
| 53 | /// The last observed remote image, retained alongside the complete local working image. | ||
| 54 | /// Observation alone does not acknowledge any pending edit's remote durability. | ||
| 55 | pub fn remote_snapshot(&self) -> Result<Vec<u8>> { | ||
| 56 | let connection = self | ||
| 57 | .connection | ||
| 58 | .lock() | ||
| 59 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 60 | Ok(connection.query_row("SELECT base FROM replica WHERE id=1", [], |row| row.get(0))?) | ||
| 61 | } | ||
| 62 | |||
| 63 | /// Reconciles one pending edit, or refreshes the working image when the queue is empty. | ||
| 64 | /// Network I/O holds synchronization ownership without holding the cache mutex. | ||
| 65 | /// Uncertain edits are never replayed; retired formatting may confirm its complete observed effect. | ||
| 66 | pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Option<(u64, EditStatus)>> { | ||
| 67 | let _owner = self.sync_owner()?; | ||
| 68 | let snapshot = remote.read().map_err(Error::RemoteIo)?; | ||
| 69 | let identity = validate(&snapshot)?; | ||
| 70 | let (intent, attempted) = { | ||
| 71 | let mut connection = self | ||
| 72 | .connection | ||
| 73 | .lock() | ||
| 74 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 75 | let transaction = | ||
| 76 | connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 77 | let base: Vec<u8> = | ||
| 78 | transaction | ||
| 79 | .query_row("SELECT base FROM replica WHERE id=1", [], |row| row.get(0))?; | ||
| 80 | let base_store = Store::parse(&base)?; | ||
| 81 | if RevisionIndex::parse(&base_store)?.root != identity { | ||
| 82 | return Err(io::Error::new( | ||
| 83 | io::ErrorKind::InvalidInput, | ||
| 84 | "Remote snapshot belongs to another document", | ||
| 85 | ) | ||
| 86 | .into()); | ||
| 87 | } | ||
| 88 | let Some(intent) = pending(&transaction)?.into_iter().next() else { | ||
| 89 | transaction.execute( | ||
| 90 | "UPDATE replica SET base=?1, working=?1 WHERE id=1", | ||
| 91 | [&snapshot], | ||
| 92 | )?; | ||
| 93 | transaction.commit()?; | ||
| 94 | return Ok(None); | ||
| 95 | }; | ||
| 96 | let attempted = transaction | ||
| 97 | .query_row( | ||
| 98 | "SELECT revisions FROM attempt WHERE edit_id=?1", | ||
| 99 | [i64::try_from(intent.id).map_err(io::Error::other)?], | ||
| 100 | |row| row.get::<_, String>(0), | ||
| 101 | ) | ||
| 102 | .optional()?; | ||
| 103 | transaction.execute("UPDATE replica SET base=?1 WHERE id=1", [&snapshot])?; | ||
| 104 | transaction.commit()?; | ||
| 105 | (intent, attempted) | ||
| 106 | }; | ||
| 107 | if let Some(encoded) = attempted { | ||
| 108 | let revisions = attempted_revisions(&encoded, intent.space)?; | ||
| 109 | let mut revision = revisions[&intent.space]; | ||
| 110 | let store = Store::parse(&snapshot)?; | ||
| 111 | let index = RevisionIndex::parse(&store)?; | ||
| 112 | if !revisions.iter().all(|(space, revision)| { | ||
| 113 | index | ||
| 114 | .spaces | ||
| 115 | .get(space) | ||
| 116 | .is_some_and(|space| space.revisions.contains_key(revision)) | ||
| 117 | }) { | ||
| 118 | let satisfied = match &intent.operation { | ||
| 119 | Operation::Format(edit) if revisions.len() == 1 => edit | ||
| 120 | .prepare(&snapshot, intent.space)? | ||
| 121 | .is_ok_and(|prepared| prepared.as_bytes() == snapshot), | ||
| 122 | _ => false, | ||
| 123 | }; | ||
| 124 | if !satisfied { | ||
| 125 | return Ok(Some(( | ||
| 126 | intent.id, | ||
| 127 | EditStatus::AwaitingConfirmation { revision }, | ||
| 128 | ))); | ||
| 129 | } | ||
| 130 | revision = index.active(intent.space)?; | ||
| 131 | } | ||
| 132 | if let Err(error) = remote.confirm(&snapshot) { | ||
| 133 | if error.state == CommitState::Committed { | ||
| 134 | self.acknowledge(intent.id, revision, &snapshot)?; | ||
| 135 | } | ||
| 136 | return Err(error.into()); | ||
| 137 | } | ||
| 138 | self.acknowledge(intent.id, revision, &snapshot)?; | ||
| 139 | return Ok(Some((intent.id, EditStatus::Published { revision }))); | ||
| 140 | } | ||
| 141 | let candidate = match &intent.operation { | ||
| 142 | Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page) | ||
| 143 | .map_err(|_| ConflictKind::StructureChanged), | ||
| 144 | Operation::Pages(edit) => edit.prepare(&snapshot, intent.space)?, | ||
| 145 | Operation::Format(edit) => edit.prepare(&snapshot, intent.space)?, | ||
| 146 | Operation::Split(edit) => edit.prepare(&snapshot, intent.space)?, | ||
| 147 | Operation::Join(edit) => edit.prepare(&snapshot, intent.space)?, | ||
| 148 | Operation::Outline(edit) => edit.prepare(&snapshot, intent.space)?, | ||
| 149 | Operation::Tree(edit) => edit.prepare(&snapshot, intent.space)?, | ||
| 150 | Operation::Insert(insertion) => { | ||
| 151 | PreparedEdit::insert(&snapshot, intent.space, insertion) | ||
| 152 | .map_err(|_| ConflictKind::UnsupportedEdit) | ||
| 153 | } | ||
| 154 | Operation::Text(edit) => paragraph(&snapshot, intent.space, edit.object)? | ||
| 155 | .ok_or(ConflictKind::TargetUnavailable) | ||
| 156 | .and_then(|text| { | ||
| 157 | crate::rebase::rebase(&edit.before, &text, edit.range.clone()) | ||
| 158 | .ok_or(ConflictKind::TextChanged) | ||
| 159 | }) | ||
| 160 | .and_then(|range| { | ||
| 161 | PreparedEdit::text( | ||
| 162 | &snapshot, | ||
| 163 | intent.space, | ||
| 164 | edit.object, | ||
| 165 | range, | ||
| 166 | &edit.replacement, | ||
| 167 | ) | ||
| 168 | .map_err(|_| ConflictKind::UnsupportedEdit) | ||
| 169 | }), | ||
| 170 | }; | ||
| 171 | let prepared = match candidate { | ||
| 172 | Ok(prepared) => prepared, | ||
| 173 | Err(kind) => { | ||
| 174 | let connection = self | ||
| 175 | .connection | ||
| 176 | .lock() | ||
| 177 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 178 | 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])?; | ||
| 179 | return Ok(Some((intent.id, EditStatus::Conflict(kind)))); | ||
| 180 | } | ||
| 181 | }; | ||
| 182 | if prepared.as_bytes() == snapshot { | ||
| 183 | let store = Store::parse(&snapshot)?; | ||
| 184 | let index = RevisionIndex::parse(&store)?; | ||
| 185 | let revision = index.active(intent.space)?; | ||
| 186 | if let Err(error) = remote.confirm(&snapshot) { | ||
| 187 | if error.state == CommitState::Committed { | ||
| 188 | self.acknowledge(intent.id, revision, &snapshot)?; | ||
| 189 | } | ||
| 190 | return Err(error.into()); | ||
| 191 | } | ||
| 192 | self.acknowledge(intent.id, revision, &snapshot)?; | ||
| 193 | return Ok(Some((intent.id, EditStatus::Published { revision }))); | ||
| 194 | } | ||
| 195 | let store = Store::parse(prepared.as_bytes())?; | ||
| 196 | let index = RevisionIndex::parse(&store)?; | ||
| 197 | let revision = index.active(intent.space)?; | ||
| 198 | let before_store = Store::parse(&snapshot)?; | ||
| 199 | let before = RevisionIndex::parse(&before_store)?; | ||
| 200 | let mut revisions: BTreeMap<_, _> = index | ||
| 201 | .spaces | ||
| 202 | .iter() | ||
| 203 | .filter_map(|(sid, space)| { | ||
| 204 | let revision = *space.labels.get(&(ExGuid::default(), 1))?; | ||
| 205 | (before | ||
| 206 | .spaces | ||
| 207 | .get(sid) | ||
| 208 | .and_then(|s| s.labels.get(&(ExGuid::default(), 1))) | ||
| 209 | != Some(&revision)) | ||
| 210 | .then_some((*sid, revision)) | ||
| 211 | }) | ||
| 212 | .collect(); | ||
| 213 | // The receipt's space can be unchanged in a multi-space edit. | ||
| 214 | revisions.insert(intent.space, revision); | ||
| 215 | { | ||
| 216 | let mut connection = self | ||
| 217 | .connection | ||
| 218 | .lock() | ||
| 219 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 220 | let transaction = | ||
| 221 | connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 222 | transaction.execute( | ||
| 223 | "DELETE FROM conflicts WHERE edit_id=?1", | ||
| 224 | [i64::try_from(intent.id).map_err(io::Error::other)?], | ||
| 225 | )?; | ||
| 226 | transaction.execute( | ||
| 227 | "INSERT INTO attempt(id, edit_id, revisions) VALUES (1, ?1, ?2)", | ||
| 228 | params![ | ||
| 229 | i64::try_from(intent.id).map_err(io::Error::other)?, | ||
| 230 | serde_json::to_string(&revisions).map_err(io::Error::other)? | ||
| 231 | ], | ||
| 232 | )?; | ||
| 233 | transaction.commit()?; | ||
| 234 | } | ||
| 235 | match remote.publish(&prepared) { | ||
| 236 | Ok(()) => {} | ||
| 237 | Err(error) if error.state == CommitState::NotCommitted => { | ||
| 238 | let connection = self | ||
| 239 | .connection | ||
| 240 | .lock() | ||
| 241 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 242 | connection.execute( | ||
| 243 | "DELETE FROM attempt WHERE edit_id=?1", | ||
| 244 | [i64::try_from(intent.id).map_err(io::Error::other)?], | ||
| 245 | )?; | ||
| 246 | return Err(error.into()); | ||
| 247 | } | ||
| 248 | Err(error) if error.state == CommitState::Committed => { | ||
| 249 | self.acknowledge(intent.id, revision, prepared.as_bytes())?; | ||
| 250 | return Err(error.into()); | ||
| 251 | } | ||
| 252 | Err(error) => return Err(error.into()), | ||
| 253 | } | ||
| 254 | self.acknowledge(intent.id, revision, prepared.as_bytes())?; | ||
| 255 | Ok(Some((intent.id, EditStatus::Published { revision }))) | ||
| 256 | } | ||
| 257 | |||
| 258 | /// Places the oldest text, formatting or split conflict at a reviewed remote UTF-16 range. | ||
| 259 | /// The requested replacement/attributes, local image, intent ID and later edits are preserved. | ||
| 260 | /// Both supplied images must match `snapshot` and `remote_snapshot`; stale review | ||
| 261 | /// returns `Io(ResourceBusy)`. Uncertain publication attempts cannot be rebased. | ||
| 262 | pub fn rebase_conflict( | ||
| 263 | &self, | ||
| 264 | id: u64, | ||
| 265 | local: &[u8], | ||
| 266 | remote: &[u8], | ||
| 267 | range: Range<u32>, | ||
| 268 | ) -> Result<()> { | ||
| 269 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 270 | Ok(match intent.operation { | ||
| 271 | Operation::Text(mut edit) => { | ||
| 272 | let prepared = PreparedEdit::text( | ||
| 273 | remote, | ||
| 274 | intent.space, | ||
| 275 | edit.object, | ||
| 276 | range.clone(), | ||
| 277 | &edit.replacement, | ||
| 278 | )?; | ||
| 279 | if prepared.as_bytes() == remote { | ||
| 280 | return Err(io::Error::new( | ||
| 281 | io::ErrorKind::InvalidInput, | ||
| 282 | "The selected range already contains the replacement", | ||
| 283 | ) | ||
| 284 | .into()); | ||
| 285 | } | ||
| 286 | edit.before = | ||
| 287 | paragraph(remote, intent.space, edit.object)?.ok_or_else(|| { | ||
| 288 | io::Error::new( | ||
| 289 | io::ErrorKind::InvalidData, | ||
| 290 | "The remote text target is unavailable", | ||
| 291 | ) | ||
| 292 | })?; | ||
| 293 | edit.range = range; | ||
| 294 | Operation::Text(edit) | ||
| 295 | } | ||
| 296 | Operation::Format(edit) => Operation::Format( | ||
| 297 | FormatEdit::capture( | ||
| 298 | remote, | ||
| 299 | intent.space, | ||
| 300 | edit.object, | ||
| 301 | range, | ||
| 302 | &edit.attributes, | ||
| 303 | )? | ||
| 304 | .0, | ||
| 305 | ), | ||
| 306 | Operation::Split(edit) => { | ||
| 307 | if !range.is_empty() { | ||
| 308 | return Err(io::Error::new( | ||
| 309 | io::ErrorKind::InvalidInput, | ||
| 310 | "Choose a single split boundary", | ||
| 311 | ) | ||
| 312 | .into()); | ||
| 313 | } | ||
| 314 | Operation::Split( | ||
| 315 | crate::paragraph::SplitEdit::capture( | ||
| 316 | remote, | ||
| 317 | intent.space, | ||
| 318 | &edit.intent.reposition(range.start), | ||
| 319 | )? | ||
| 320 | .0, | ||
| 321 | ) | ||
| 322 | } | ||
| 323 | Operation::Join(_) => { | ||
| 324 | return Err(io::Error::new( | ||
| 325 | io::ErrorKind::InvalidInput, | ||
| 326 | "Review the paragraph join using rebase_join_conflict", | ||
| 327 | ) | ||
| 328 | .into()); | ||
| 329 | } | ||
| 330 | Operation::Outline(_) => { | ||
| 331 | return Err(io::Error::new( | ||
| 332 | io::ErrorKind::InvalidInput, | ||
| 333 | "Review layout changes using rebase_layout_conflict", | ||
| 334 | ) | ||
| 335 | .into()); | ||
| 336 | } | ||
| 337 | Operation::Tree(_) => { | ||
| 338 | return Err(io::Error::new( | ||
| 339 | io::ErrorKind::InvalidInput, | ||
| 340 | "Review the subtree edit using rebase_tree_conflict", | ||
| 341 | ) | ||
| 342 | .into()); | ||
| 343 | } | ||
| 344 | Operation::Insert(_) => { | ||
| 345 | return Err(io::Error::new( | ||
| 346 | io::ErrorKind::InvalidInput, | ||
| 347 | "Insertion conflicts require a placement, not a text range", | ||
| 348 | ) | ||
| 349 | .into()); | ||
| 350 | } | ||
| 351 | Operation::CreatePage(_) => { | ||
| 352 | return Err(io::Error::new( | ||
| 353 | io::ErrorKind::InvalidInput, | ||
| 354 | "Review page placement using rebase_page_creation_conflict", | ||
| 355 | ) | ||
| 356 | .into()); | ||
| 357 | } | ||
| 358 | Operation::Pages(_) => { | ||
| 359 | return Err(io::Error::new( | ||
| 360 | io::ErrorKind::InvalidInput, | ||
| 361 | "Review page edits using rebase_pages_conflict", | ||
| 362 | ) | ||
| 363 | .into()); | ||
| 364 | } | ||
| 365 | }) | ||
| 366 | }) | ||
| 367 | } | ||
| 368 | |||
| 369 | /// Repositions an unattempted page-creation conflict, retaining dependent object identities. | ||
| 370 | pub fn rebase_page_creation_conflict( | ||
| 371 | &self, | ||
| 372 | id: u64, | ||
| 373 | local: &[u8], | ||
| 374 | remote: &[u8], | ||
| 375 | before: Option<ExGuid>, | ||
| 376 | ) -> Result<()> { | ||
| 377 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 378 | let Operation::CreatePage(page) = intent.operation else { | ||
| 379 | return Err(io::Error::new( | ||
| 380 | io::ErrorKind::InvalidInput, | ||
| 381 | "Select a page-creation conflict", | ||
| 382 | ) | ||
| 383 | .into()); | ||
| 384 | }; | ||
| 385 | let page = page.reposition(before)?; | ||
| 386 | PreparedEdit::create_page(remote, &page)?; | ||
| 387 | Ok(Operation::CreatePage(page)) | ||
| 388 | }) | ||
| 389 | } | ||
| 390 | |||
| 391 | /// Reviews a page batch against both cache images, retaining its page and series identities. | ||
| 392 | pub fn rebase_pages_conflict( | ||
| 393 | &self, | ||
| 394 | id: u64, | ||
| 395 | local: &[u8], | ||
| 396 | remote: &[u8], | ||
| 397 | edits: &[onestore::PageEdit], | ||
| 398 | ) -> Result<()> { | ||
| 399 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 400 | let Operation::Pages(batch) = intent.operation else { | ||
| 401 | return Err(io::Error::new( | ||
| 402 | io::ErrorKind::InvalidInput, | ||
| 403 | "Select a page movement or indentation conflict", | ||
| 404 | ) | ||
| 405 | .into()); | ||
| 406 | }; | ||
| 407 | Ok(Operation::Pages(batch.review( | ||
| 408 | remote, | ||
| 409 | intent.space, | ||
| 410 | edits, | ||
| 411 | )?)) | ||
| 412 | }) | ||
| 413 | } | ||
| 414 | |||
| 415 | /// Reviews a join against both current cache images, retaining its original text identities. | ||
| 416 | /// The surviving text identity must remain the same for dependent edits. | ||
| 417 | pub fn rebase_join_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> { | ||
| 418 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 419 | let Operation::Join(edit) = intent.operation else { | ||
| 420 | return Err(io::Error::new( | ||
| 421 | io::ErrorKind::InvalidInput, | ||
| 422 | "Select a paragraph join conflict", | ||
| 423 | ) | ||
| 424 | .into()); | ||
| 425 | }; | ||
| 426 | Ok(Operation::Join(edit.review(remote, intent.space)?)) | ||
| 427 | }) | ||
| 428 | } | ||
| 429 | |||
| 430 | /// Repositions the oldest paragraph insertion conflict against reviewed cache images. | ||
| 431 | /// Object identities and dependent edits are retained; uncertain attempts cannot be moved. | ||
| 432 | pub fn rebase_paragraph_conflict( | ||
| 433 | &self, | ||
| 434 | id: u64, | ||
| 435 | local: &[u8], | ||
| 436 | remote: &[u8], | ||
| 437 | parent: ExGuid, | ||
| 438 | before: Option<ExGuid>, | ||
| 439 | ) -> Result<()> { | ||
| 440 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 441 | let Operation::Insert(insertion) = intent.operation else { | ||
| 442 | return Err(io::Error::new( | ||
| 443 | io::ErrorKind::InvalidInput, | ||
| 444 | "Select a paragraph insertion conflict", | ||
| 445 | ) | ||
| 446 | .into()); | ||
| 447 | }; | ||
| 448 | let insertion = insertion.reposition_paragraph(parent, before)?; | ||
| 449 | PreparedEdit::insert(remote, intent.space, &insertion)?; | ||
| 450 | Ok(Operation::Insert(insertion)) | ||
| 451 | }) | ||
| 452 | } | ||
| 453 | |||
| 454 | /// Repositions the oldest outline insertion conflict against reviewed cache images. | ||
| 455 | /// Object identities and dependent edits are retained; uncertain attempts cannot be moved. | ||
| 456 | pub fn rebase_outline_conflict( | ||
| 457 | &self, | ||
| 458 | id: u64, | ||
| 459 | local: &[u8], | ||
| 460 | remote: &[u8], | ||
| 461 | page: ExGuid, | ||
| 462 | x: f32, | ||
| 463 | y: f32, | ||
| 464 | ) -> Result<()> { | ||
| 465 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 466 | let Operation::Insert(insertion) = intent.operation else { | ||
| 467 | return Err(io::Error::new( | ||
| 468 | io::ErrorKind::InvalidInput, | ||
| 469 | "Select an outline insertion conflict", | ||
| 470 | ) | ||
| 471 | .into()); | ||
| 472 | }; | ||
| 473 | let insertion = insertion.reposition_outline(page, x, y)?; | ||
| 474 | PreparedEdit::insert(remote, intent.space, &insertion)?; | ||
| 475 | Ok(Operation::Insert(insertion)) | ||
| 476 | }) | ||
| 477 | } | ||
| 478 | |||
| 479 | /// Reviews the original layout intent against both current cache images. | ||
| 480 | /// Competing property values are replaced only after this explicit review. | ||
| 481 | pub fn rebase_layout_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> { | ||
| 482 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 483 | let Operation::Outline(edit) = intent.operation else { | ||
| 484 | return Err(io::Error::new( | ||
| 485 | io::ErrorKind::InvalidInput, | ||
| 486 | "Select an outline layout conflict", | ||
| 487 | ) | ||
| 488 | .into()); | ||
| 489 | }; | ||
| 490 | Ok(Operation::Outline( | ||
| 491 | OutlineEdit::capture(remote, intent.space, edit.object, edit.change)?.0, | ||
| 492 | )) | ||
| 493 | }) | ||
| 494 | } | ||
| 495 | |||
| 496 | /// Reviews the original subtree intent against both current cache images. | ||
| 497 | /// Replacement paragraph identities must remain unchanged for dependent edits. | ||
| 498 | pub fn rebase_tree_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> { | ||
| 499 | self.resolve_conflict(id, local, remote, |intent| { | ||
| 500 | let Operation::Tree(edit) = intent.operation else { | ||
| 501 | return Err(io::Error::new( | ||
| 502 | io::ErrorKind::InvalidInput, | ||
| 503 | "Select a subtree move or deletion conflict", | ||
| 504 | ) | ||
| 505 | .into()); | ||
| 506 | }; | ||
| 507 | Ok(Operation::Tree(edit.review(remote, intent.space)?)) | ||
| 508 | }) | ||
| 509 | } | ||
| 510 | |||
| 511 | fn resolve_conflict( | ||
| 512 | &self, | ||
| 513 | id: u64, | ||
| 514 | local: &[u8], | ||
| 515 | remote: &[u8], | ||
| 516 | update: impl FnOnce(PendingEdit) -> Result<Operation>, | ||
| 517 | ) -> Result<()> { | ||
| 518 | let owner = self.sync_owner()?; | ||
| 519 | let intent = self | ||
| 520 | .pending()? | ||
| 521 | .into_iter() | ||
| 522 | .next() | ||
| 523 | .filter(|intent| intent.id == id) | ||
| 524 | .ok_or_else(|| { | ||
| 525 | io::Error::new( | ||
| 526 | io::ErrorKind::InvalidInput, | ||
| 527 | "Only the oldest conflict can be rebased", | ||
| 528 | ) | ||
| 529 | })?; | ||
| 530 | let operation = update(intent)?; | ||
| 531 | let mut connection = self | ||
| 532 | .connection | ||
| 533 | .lock() | ||
| 534 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 535 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 536 | let (base, working): (Vec<u8>, Vec<u8>) = | ||
| 537 | transaction.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| { | ||
| 538 | Ok((row.get(0)?, row.get(1)?)) | ||
| 539 | })?; | ||
| 540 | if working != local || base != remote { | ||
| 541 | return Err(io::Error::new( | ||
| 542 | io::ErrorKind::ResourceBusy, | ||
| 543 | "The reviewed cache images changed", | ||
| 544 | ) | ||
| 545 | .into()); | ||
| 546 | } | ||
| 547 | let id = i64::try_from(id).map_err(io::Error::other)?; | ||
| 548 | let eligible: bool = transaction.query_row( | ||
| 549 | "SELECT EXISTS(SELECT 1 FROM conflicts WHERE edit_id=?1) AND NOT EXISTS(SELECT 1 FROM attempt)", | ||
| 550 | [id], |row| row.get(0), | ||
| 551 | )?; | ||
| 552 | if !eligible { | ||
| 553 | return Err(io::Error::new( | ||
| 554 | io::ErrorKind::InvalidInput, | ||
| 555 | "The edit is not an unattempted conflict", | ||
| 556 | ) | ||
| 557 | .into()); | ||
| 558 | } | ||
| 559 | transaction.execute( | ||
| 560 | "UPDATE edits SET operation=?1 WHERE id=?2", | ||
| 561 | params![ | ||
| 562 | serde_json::to_string(&operation).map_err(io::Error::other)?, | ||
| 563 | id | ||
| 564 | ], | ||
| 565 | )?; | ||
| 566 | transaction.execute("DELETE FROM conflicts WHERE edit_id=?1", [id])?; | ||
| 567 | transaction.commit()?; | ||
| 568 | drop(connection); | ||
| 569 | drop(owner); | ||
| 570 | self.wake_sync(); | ||
| 571 | Ok(()) | ||
| 572 | } | ||
| 573 | |||
| 574 | fn sync_owner(&self) -> Result<MutexGuard<'_, ()>> { | ||
| 575 | Ok(self | ||
| 576 | .synchronization | ||
| 577 | .try_lock() | ||
| 578 | .map_err(|error| match error { | ||
| 579 | TryLockError::WouldBlock => io::Error::from(io::ErrorKind::WouldBlock), | ||
| 580 | TryLockError::Poisoned(_) => { | ||
| 581 | io::Error::other("Synchronization owner panicked; reopen the cache") | ||
| 582 | } | ||
| 583 | })?) | ||
| 584 | } | ||
| 585 | |||
| 586 | fn acknowledge(&self, id: u64, revision: ExGuid, snapshot: &[u8]) -> Result<()> { | ||
| 587 | let id = i64::try_from(id).map_err(io::Error::other)?; | ||
| 588 | let mut connection = self | ||
| 589 | .connection | ||
| 590 | .lock() | ||
| 591 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | ||
| 592 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | ||
| 593 | transaction.execute( | ||
| 594 | "INSERT INTO receipts(edit_id, revision) VALUES (?1, ?2)", | ||
| 595 | params![id, revision.to_string()], | ||
| 596 | )?; | ||
| 597 | transaction.execute("DELETE FROM edits WHERE id=?1", [id])?; | ||
| 598 | transaction.execute("UPDATE replica SET base=?1, working=CASE WHEN EXISTS(SELECT 1 FROM edits) THEN working ELSE ?1 END WHERE id=1", [snapshot])?; | ||
| 599 | transaction.commit()?; | ||
| 600 | Ok(()) | ||
| 601 | } | ||
| 602 | } | ||
| 603 | |||
| 604 | pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStatus>> { | ||
| 605 | let id = i64::try_from(id).map_err(io::Error::other)?; | ||
| 606 | if let Some(revision) = connection | ||
| 607 | .query_row( | ||
| 608 | "SELECT revision FROM receipts WHERE edit_id=?1", | ||
| 609 | [id], | ||
| 610 | |row| row.get::<_, String>(0), | ||
| 611 | ) | ||
| 612 | .optional()? | ||
| 613 | { | ||
| 614 | return Ok(Some(EditStatus::Published { | ||
| 615 | revision: revision.parse()?, | ||
| 616 | })); | ||
| 617 | } | ||
| 618 | let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; | ||
| 619 | let column = if version < 10 { | ||
| 620 | "revision" | ||
| 621 | } else { | ||
| 622 | "revisions" | ||
| 623 | }; | ||
| 624 | let record: Option<(Option<String>, Option<i64>, String)> = connection.query_row( | ||
| 625 | &format!("SELECT attempt.{column}, conflicts.kind, edits.space FROM edits LEFT JOIN attempt ON attempt.edit_id=edits.id LEFT JOIN conflicts ON conflicts.edit_id=edits.id WHERE edits.id=?1"), [id], |row| Ok((row.get(0)?,row.get(1)?, row.get(2)?))).optional()?; | ||
| 626 | Ok(match record { | ||
| 627 | None => None, | ||
| 628 | Some((Some(revision), _, space)) => Some(EditStatus::AwaitingConfirmation { | ||
| 629 | revision: if version < 10 { | ||
| 630 | revision.parse()? | ||
| 631 | } else { | ||
| 632 | let space = space.parse()?; | ||
| 633 | attempted_revisions(&revision, space)?[&space] | ||
| 634 | }, | ||
| 635 | }), | ||
| 636 | Some((None, Some(kind), _)) => Some(EditStatus::Conflict(match kind { | ||
| 637 | 0 => ConflictKind::TextChanged, | ||
| 638 | 1 => ConflictKind::TargetUnavailable, | ||
| 639 | 2 => ConflictKind::UnsupportedEdit, | ||
| 640 | 3 => ConflictKind::FormattingChanged, | ||
| 641 | 4 => ConflictKind::StructureChanged, | ||
| 642 | 5 => ConflictKind::LayoutChanged, | ||
| 643 | 6 => ConflictKind::ContentChanged, | ||
| 644 | _ => { | ||
| 645 | return Err(io::Error::new( | ||
| 646 | io::ErrorKind::InvalidData, | ||
| 647 | "Unknown cached conflict kind", | ||
| 648 | ) | ||
| 649 | .into()); | ||
| 650 | } | ||
| 651 | })), | ||
| 652 | Some((None, None, _)) => Some(EditStatus::Pending), | ||
| 653 | }) | ||
| 654 | } | ||
| 655 | |||
| 656 | fn attempted_revisions(encoded: &str, space: ExGuid) -> Result<BTreeMap<ExGuid, ExGuid>> { | ||
| 657 | let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(encoded) | ||
| 658 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; | ||
| 659 | if !revisions.contains_key(&space) | ||
| 660 | || revisions | ||
| 661 | .iter() | ||
| 662 | .any(|(sid, rid)| sid.guid == [0; 16] || rid.guid == [0; 16]) | ||
| 663 | { | ||
| 664 | return Err(io::Error::new( | ||
| 665 | io::ErrorKind::InvalidData, | ||
| 666 | "Cached publication evidence is incomplete", | ||
| 667 | ) | ||
| 668 | .into()); | ||
| 669 | } | ||
| 670 | Ok(revisions) | ||
| 671 | } | ||
crates/onestore-offline/src/tree.rs deleted-541| ... | @@ -1,541 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{FileDataReference, IdStream, ObjectData, PropertySets, ResolvedRevision, Value}; | ||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | use sha2::{Digest, Sha256}; | ||
| 5 | use std::collections::{BTreeMap, BTreeSet}; | ||
| 6 | |||
| 7 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 8 | #[serde(untagged, deny_unknown_fields)] | ||
| 9 | enum Content { | ||
| 10 | Legacy([u8; 32]), | ||
| 11 | Semantic { sha256: [u8; 32] }, | ||
| 12 | } | ||
| 13 | |||
| 14 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 15 | #[serde(deny_unknown_fields)] | ||
| 16 | struct Observed { | ||
| 17 | path: Vec<(ExGuid, u8)>, | ||
| 18 | siblings: BTreeMap<ExGuid, bool>, | ||
| 19 | destination: Vec<(ExGuid, u8)>, | ||
| 20 | content: Option<Content>, | ||
| 21 | } | ||
| 22 | |||
| 23 | /// A subtree intent with observed placement, deletion content and replacement identities. | ||
| 24 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 25 | #[serde(deny_unknown_fields)] | ||
| 26 | pub struct TreeEdit { | ||
| 27 | pub intent: onestore::TreeEdit, | ||
| 28 | observed: Observed, | ||
| 29 | created: BTreeSet<ExGuid>, | ||
| 30 | } | ||
| 31 | |||
| 32 | fn fingerprint( | ||
| 33 | store: &Store<'_>, | ||
| 34 | raw: &ResolvedRevision<'_>, | ||
| 35 | root: ExGuid, | ||
| 36 | legacy: bool, | ||
| 37 | ) -> Result<[u8; 32]> { | ||
| 38 | let mut objects = BTreeMap::new(); | ||
| 39 | let mut visiting = BTreeSet::new(); | ||
| 40 | let mut pending = vec![(root, false)]; | ||
| 41 | while let Some((id, complete)) = pending.pop() { | ||
| 42 | if objects.contains_key(&id) { | ||
| 43 | continue; | ||
| 44 | } | ||
| 45 | let object = &raw.objects[&id]; | ||
| 46 | let references = object.references()?; | ||
| 47 | if !complete { | ||
| 48 | if !visiting.insert(id) { | ||
| 49 | return Err(io::Error::new( | ||
| 50 | io::ErrorKind::InvalidData, | ||
| 51 | "Subtree property references form a cycle", | ||
| 52 | ) | ||
| 53 | .into()); | ||
| 54 | } | ||
| 55 | pending.push((id, true)); | ||
| 56 | pending.extend(references.objects.iter().map(|id| (*id, false))); | ||
| 57 | continue; | ||
| 58 | } | ||
| 59 | visiting.remove(&id); | ||
| 60 | let mut hash = Sha256::new(); | ||
| 61 | hash.update(object.jcid.to_le_bytes()); | ||
| 62 | match object.data { | ||
| 63 | ObjectData::Properties(bytes) => { | ||
| 64 | hash.update([0]); | ||
| 65 | let properties = PropertySets::parse(bytes)?; | ||
| 66 | let mut object_ids = references.objects.into_iter(); | ||
| 67 | let mut spaces = references.object_spaces.into_iter(); | ||
| 68 | let mut contexts = references.contexts.into_iter(); | ||
| 69 | let mut fields = Vec::new(); | ||
| 70 | for set in &properties.sets { | ||
| 71 | let mut values = BTreeMap::new(); | ||
| 72 | for property in set { | ||
| 73 | let mut value = Sha256::new(); | ||
| 74 | match &property.value { | ||
| 75 | Value::NoData => {} | ||
| 76 | Value::Bytes(bytes) => value.update(bytes), | ||
| 77 | Value::References { | ||
| 78 | stream, | ||
| 79 | compact_ids, | ||
| 80 | } => { | ||
| 81 | let references = match stream { | ||
| 82 | IdStream::Objects => &mut object_ids, | ||
| 83 | IdStream::ObjectSpaces => &mut spaces, | ||
| 84 | IdStream::Contexts => &mut contexts, | ||
| 85 | }; | ||
| 86 | for reference in references.take(compact_ids.len() / 4) { | ||
| 87 | if !legacy && *stream == IdStream::Objects { | ||
| 88 | let immutable = | ||
| 89 | raw.objects[&reference].jcid & 0x100000 != 0; | ||
| 90 | value.update([u8::from(immutable)]); | ||
| 91 | if !immutable { | ||
| 92 | value.update(reference.guid); | ||
| 93 | value.update(reference.n.to_le_bytes()); | ||
| 94 | } | ||
| 95 | value.update(objects[&reference]); | ||
| 96 | } else { | ||
| 97 | value.update(reference.guid); | ||
| 98 | value.update(reference.n.to_le_bytes()); | ||
| 99 | } | ||
| 100 | } | ||
| 101 | } | ||
| 102 | Value::Sets(_) => {} | ||
| 103 | } | ||
| 104 | if property.id == 0x14001d7a | ||
| 105 | || (!legacy | ||
| 106 | && property.id == 0x24001c20 | ||
| 107 | && matches!(&property.value, Value::References { compact_ids, .. } if compact_ids.is_empty())) | ||
| 108 | { | ||
| 109 | continue; | ||
| 110 | } | ||
| 111 | values.insert(property.id, value); | ||
| 112 | } | ||
| 113 | fields.push(values); | ||
| 114 | } | ||
| 115 | // Arena indices and CompactIDs can change without changing property content. | ||
| 116 | let mut sets = vec![[0; 32]; properties.sets.len()]; | ||
| 117 | for at in (0..properties.sets.len()).rev() { | ||
| 118 | for property in &properties.sets[at] { | ||
| 119 | if let Value::Sets(children) = &property.value { | ||
| 120 | let value = fields[at].get_mut(&property.id).unwrap(); | ||
| 121 | for child in children.clone() { | ||
| 122 | value.update(sets[child]); | ||
| 123 | } | ||
| 124 | } | ||
| 125 | } | ||
| 126 | let mut set = Sha256::new(); | ||
| 127 | for (id, value) in &fields[at] { | ||
| 128 | set.update(id.to_le_bytes()); | ||
| 129 | set.update(value.clone().finalize()); | ||
| 130 | } | ||
| 131 | sets[at] = set.finalize().into(); | ||
| 132 | } | ||
| 133 | hash.update(sets[0]); | ||
| 134 | } | ||
| 135 | ObjectData::File { | ||
| 136 | reference, | ||
| 137 | extension, | ||
| 138 | } => { | ||
| 139 | hash.update([1]); | ||
| 140 | hash.update(Sha256::digest(reference)); | ||
| 141 | hash.update(Sha256::digest(extension)); | ||
| 142 | if let Some(FileDataReference::Internal(guid)) = object.file_reference()? { | ||
| 143 | hash.update(Sha256::digest(store.file_data(guid)?)); | ||
| 144 | } | ||
| 145 | } | ||
| 146 | ObjectData::Encrypted(_) => unreachable!("references rejected encrypted content"), | ||
| 147 | } | ||
| 148 | objects.insert(id, <[u8; 32]>::from(hash.finalize())); | ||
| 149 | } | ||
| 150 | if !legacy { | ||
| 151 | return Ok(objects[&root]); | ||
| 152 | } | ||
| 153 | let mut hash = Sha256::new(); | ||
| 154 | for (id, value) in objects { | ||
| 155 | hash.update(id.guid); | ||
| 156 | hash.update(id.n.to_le_bytes()); | ||
| 157 | hash.update(value); | ||
| 158 | } | ||
| 159 | Ok(hash.finalize().into()) | ||
| 160 | } | ||
| 161 | |||
| 162 | fn observe( | ||
| 163 | source: &[u8], | ||
| 164 | space: ExGuid, | ||
| 165 | intent: &onestore::TreeEdit, | ||
| 166 | legacy: bool, | ||
| 167 | ) -> Result<Option<Observed>> { | ||
| 168 | let store = Store::parse(source)?; | ||
| 169 | let index = RevisionIndex::parse(&store)?; | ||
| 170 | let document = Document::parse(&index)?; | ||
| 171 | let Ok(view) = document.active(space) else { | ||
| 172 | return Ok(None); | ||
| 173 | }; | ||
| 174 | let pages = document.pages_in(space)?; | ||
| 175 | if pages.len() != 1 { | ||
| 176 | return Ok(None); | ||
| 177 | } | ||
| 178 | let object = intent.object(); | ||
| 179 | let mut targets = vec![object]; | ||
| 180 | if let Some((parent, _)) = intent.destination() { | ||
| 181 | targets.push(parent); | ||
| 182 | } | ||
| 183 | let Some(paths) = active_paths(view, &pages, &targets) else { | ||
| 184 | return Ok(None); | ||
| 185 | }; | ||
| 186 | let Some(parent) = paths[0].first() else { | ||
| 187 | return Ok(None); | ||
| 188 | }; | ||
| 189 | let children = &view.nodes[parent].children; | ||
| 190 | let Some(position) = children.iter().position(|id| *id == object) else { | ||
| 191 | return Ok(None); | ||
| 192 | }; | ||
| 193 | let levels = |path: &[ExGuid]| { | ||
| 194 | path.iter() | ||
| 195 | .map(|id| (*id, view.nodes[id].child_level.unwrap_or(1))) | ||
| 196 | .collect::<Vec<_>>() | ||
| 197 | }; | ||
| 198 | let destination = if let Some((parent, _)) = intent.destination() { | ||
| 199 | levels(&[&[parent], paths[1].as_slice()].concat()) | ||
| 200 | } else { | ||
| 201 | Vec::new() | ||
| 202 | }; | ||
| 203 | Ok(Some(Observed { | ||
| 204 | path: levels(&paths[0]), | ||
| 205 | siblings: children | ||
| 206 | .iter() | ||
| 207 | .enumerate() | ||
| 208 | .filter_map(|(at, id)| (*id != object).then_some((*id, at < position))) | ||
| 209 | .collect(), | ||
| 210 | destination, | ||
| 211 | content: if intent.destination().is_none() { | ||
| 212 | let sha256 = fingerprint(&store, &index.resolve_active(space)?, object, legacy)?; | ||
| 213 | Some(if legacy { | ||
| 214 | Content::Legacy(sha256) | ||
| 215 | } else { | ||
| 216 | Content::Semantic { sha256 } | ||
| 217 | }) | ||
| 218 | } else { | ||
| 219 | None | ||
| 220 | }, | ||
| 221 | })) | ||
| 222 | } | ||
| 223 | |||
| 224 | fn mutable_objects(source: &[u8], space: ExGuid) -> Result<BTreeSet<ExGuid>> { | ||
| 225 | let store = Store::parse(source)?; | ||
| 226 | let index = RevisionIndex::parse(&store)?; | ||
| 227 | let raw = index.resolve_active(space)?; | ||
| 228 | Ok(raw | ||
| 229 | .reachable()? | ||
| 230 | .into_iter() | ||
| 231 | .filter(|id| raw.objects[id].jcid & 0x100000 == 0) | ||
| 232 | .collect()) | ||
| 233 | } | ||
| 234 | |||
| 235 | impl Replica { | ||
| 236 | /// Queues a move or deletion, retaining content changes for explicit deletion review. | ||
| 237 | pub fn tree( | ||
| 238 | &self, | ||
| 239 | source: &[u8], | ||
| 240 | space: ExGuid, | ||
| 241 | intent: &onestore::TreeEdit, | ||
| 242 | ) -> Result<Option<u64>> { | ||
| 243 | let (edit, prepared) = TreeEdit::capture(source, space, intent)?; | ||
| 244 | self.record(source, space, Operation::Tree(edit), &prepared) | ||
| 245 | } | ||
| 246 | } | ||
| 247 | |||
| 248 | impl TreeEdit { | ||
| 249 | pub(crate) fn capture<'a>( | ||
| 250 | source: &'a [u8], | ||
| 251 | space: ExGuid, | ||
| 252 | intent: &onestore::TreeEdit, | ||
| 253 | ) -> Result<(Self, PreparedEdit<'a>)> { | ||
| 254 | let prepared = PreparedEdit::tree(source, space, intent)?; | ||
| 255 | let observed = observe(source, space, intent, false)?.ok_or_else(|| { | ||
| 256 | io::Error::new( | ||
| 257 | io::ErrorKind::InvalidData, | ||
| 258 | "Prepared subtree edit has no active target", | ||
| 259 | ) | ||
| 260 | })?; | ||
| 261 | let before = mutable_objects(source, space)?; | ||
| 262 | let after = mutable_objects(prepared.as_bytes(), space)?; | ||
| 263 | Ok(( | ||
| 264 | Self { | ||
| 265 | intent: intent.clone(), | ||
| 266 | observed, | ||
| 267 | created: &after - &before, | ||
| 268 | }, | ||
| 269 | prepared, | ||
| 270 | )) | ||
| 271 | } | ||
| 272 | |||
| 273 | pub(crate) fn prepare<'a>( | ||
| 274 | &self, | ||
| 275 | source: &'a [u8], | ||
| 276 | space: ExGuid, | ||
| 277 | ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> { | ||
| 278 | let Some(current) = observe( | ||
| 279 | source, | ||
| 280 | space, | ||
| 281 | &self.intent, | ||
| 282 | matches!(self.observed.content, Some(Content::Legacy(_))), | ||
| 283 | )? | ||
| 284 | else { | ||
| 285 | return Ok(Err(ConflictKind::TargetUnavailable)); | ||
| 286 | }; | ||
| 287 | let prepared = match PreparedEdit::tree(source, space, &self.intent) { | ||
| 288 | Ok(prepared) => prepared, | ||
| 289 | Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)), | ||
| 290 | }; | ||
| 291 | let after = mutable_objects(prepared.as_bytes(), space)?; | ||
| 292 | if prepared.as_bytes() == source { | ||
| 293 | return Ok(if self.created.is_subset(&after) { | ||
| 294 | Ok(prepared) | ||
| 295 | } else { | ||
| 296 | Err(ConflictKind::StructureChanged) | ||
| 297 | }); | ||
| 298 | } | ||
| 299 | if current.path != self.observed.path | ||
| 300 | || current.destination != self.observed.destination | ||
| 301 | || current.siblings.iter().any(|(id, before)| { | ||
| 302 | self.observed | ||
| 303 | .siblings | ||
| 304 | .get(id) | ||
| 305 | .is_some_and(|was_before| before != was_before) | ||
| 306 | }) | ||
| 307 | || &after - &mutable_objects(source, space)? != self.created | ||
| 308 | { | ||
| 309 | return Ok(Err(ConflictKind::StructureChanged)); | ||
| 310 | } | ||
| 311 | if current.content != self.observed.content { | ||
| 312 | return Ok(Err(ConflictKind::ContentChanged)); | ||
| 313 | } | ||
| 314 | Ok(Ok(prepared)) | ||
| 315 | } | ||
| 316 | |||
| 317 | pub(crate) fn review(&self, source: &[u8], space: ExGuid) -> Result<Self> { | ||
| 318 | let (reviewed, _) = Self::capture(source, space, &self.intent)?; | ||
| 319 | if reviewed.created != self.created { | ||
| 320 | return Err(io::Error::new( | ||
| 321 | io::ErrorKind::InvalidInput, | ||
| 322 | "The subtree edit would change its replacement paragraph identities", | ||
| 323 | ) | ||
| 324 | .into()); | ||
| 325 | } | ||
| 326 | Ok(reviewed) | ||
| 327 | } | ||
| 328 | } | ||
| 329 | |||
| 330 | #[cfg(test)] | ||
| 331 | mod tests { | ||
| 332 | use super::*; | ||
| 333 | use onestore::Object; | ||
| 334 | use std::sync::Arc; | ||
| 335 | |||
| 336 | fn set(fields: &[(u32, Vec<u8>)]) -> Vec<u8> { | ||
| 337 | let mut bytes = u16::try_from(fields.len()).unwrap().to_le_bytes().to_vec(); | ||
| 338 | for (id, _) in fields { | ||
| 339 | bytes.extend(id.to_le_bytes()); | ||
| 340 | } | ||
| 341 | for (_, value) in fields { | ||
| 342 | bytes.extend(value); | ||
| 343 | } | ||
| 344 | bytes | ||
| 345 | } | ||
| 346 | |||
| 347 | #[test] | ||
| 348 | fn deletion_fingerprint_resolves_references_and_nested_sets_without_storage_order() { | ||
| 349 | let source = onestore::create_section("fingerprint.one", "Original", "Author").unwrap(); | ||
| 350 | let store = Store::parse(&source).unwrap(); | ||
| 351 | let root = ExGuid { | ||
| 352 | guid: [1; 16], | ||
| 353 | n: 1, | ||
| 354 | }; | ||
| 355 | let child = ExGuid { | ||
| 356 | guid: [2; 16], | ||
| 357 | n: 7, | ||
| 358 | }; | ||
| 359 | let hash = |reverse: bool, global: u32, timestamp: u32, change: usize, immutable: bool| { | ||
| 360 | let mut fields = vec![ | ||
| 361 | (0x14001d7a, timestamp.to_le_bytes().to_vec()), | ||
| 362 | (0x20000001, vec![]), | ||
| 363 | ( | ||
| 364 | 0x44000002, | ||
| 365 | set(&[(0x14000003, (u32::from(change == 1)).to_le_bytes().to_vec())]), | ||
| 366 | ), | ||
| 367 | ( | ||
| 368 | 0x44000004, | ||
| 369 | set(&[(if change == 2 { 0x08000005 } else { 0x88000005 }, vec![])]), | ||
| 370 | ), | ||
| 371 | ]; | ||
| 372 | if change != 5 { | ||
| 373 | fields.push((0x24001c20, 0_u32.to_le_bytes().to_vec())); | ||
| 374 | } | ||
| 375 | if change == 6 { | ||
| 376 | fields.push((0x24000007, 0_u32.to_le_bytes().to_vec())); | ||
| 377 | } | ||
| 378 | if reverse { | ||
| 379 | fields.reverse(); | ||
| 380 | } | ||
| 381 | let mut bytes = 0x80000001_u32.to_le_bytes().to_vec(); | ||
| 382 | bytes.extend(((global << 8) | child.n).to_le_bytes()); | ||
| 383 | bytes.extend(set(&fields)); | ||
| 384 | let mut child_bytes = 0x80000000_u32.to_le_bytes().to_vec(); | ||
| 385 | child_bytes.extend(set(&[( | ||
| 386 | 0x14000006, | ||
| 387 | (u32::from(change == 3)).to_le_bytes().to_vec(), | ||
| 388 | )])); | ||
| 389 | let ids = Arc::new(BTreeMap::from([( | ||
| 390 | global, | ||
| 391 | if change == 4 { [3; 16] } else { child.guid }, | ||
| 392 | )])); | ||
| 393 | let target = ExGuid { | ||
| 394 | guid: ids[&global], | ||
| 395 | ..child | ||
| 396 | }; | ||
| 397 | let raw = ResolvedRevision { | ||
| 398 | roots: BTreeMap::from([(1, root)]), | ||
| 399 | objects: BTreeMap::from([ | ||
| 400 | ( | ||
| 401 | root, | ||
| 402 | Object { | ||
| 403 | jcid: 0x6000d, | ||
| 404 | reference_count: 1, | ||
| 405 | data: ObjectData::Properties(&bytes), | ||
| 406 | global_ids: ids.clone(), | ||
| 407 | }, | ||
| 408 | ), | ||
| 409 | ( | ||
| 410 | target, | ||
| 411 | Object { | ||
| 412 | jcid: if immutable { 0x12004d } else { 0x6000e }, | ||
| 413 | reference_count: 1, | ||
| 414 | data: ObjectData::Properties(&child_bytes), | ||
| 415 | global_ids: ids, | ||
| 416 | }, | ||
| 417 | ), | ||
| 418 | ]), | ||
| 419 | }; | ||
| 420 | fingerprint(&store, &raw, root, false).unwrap() | ||
| 421 | }; | ||
| 422 | let original = hash(false, 0, 1, 0, false); | ||
| 423 | assert_eq!(original, hash(true, 137, 2, 0, false)); | ||
| 424 | assert_eq!(original, hash(true, 137, 2, 5, false)); | ||
| 425 | assert_ne!(original, hash(true, 137, 2, 6, false)); | ||
| 426 | for change in 1..=4 { | ||
| 427 | assert_ne!(original, hash(true, 137, 2, change, false)); | ||
| 428 | } | ||
| 429 | let immutable = hash(false, 0, 1, 0, true); | ||
| 430 | assert_eq!(immutable, hash(true, 137, 2, 4, true)); | ||
| 431 | for change in 1..=3 { | ||
| 432 | assert_ne!(immutable, hash(true, 137, 2, change, true)); | ||
| 433 | } | ||
| 434 | } | ||
| 435 | |||
| 436 | #[test] | ||
| 437 | fn version_eight_deletion_observations_survive_migration_and_upgrade_only_after_review() { | ||
| 438 | struct Server(Vec<u8>); | ||
| 439 | impl Remote for Server { | ||
| 440 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 441 | Ok(self.0.clone()) | ||
| 442 | } | ||
| 443 | fn publish( | ||
| 444 | &mut self, | ||
| 445 | edit: &PreparedEdit<'_>, | ||
| 446 | ) -> std::result::Result<(), onestore::CommitError> { | ||
| 447 | self.0 = edit.as_bytes().to_vec(); | ||
| 448 | Ok(()) | ||
| 449 | } | ||
| 450 | fn confirm( | ||
| 451 | &mut self, | ||
| 452 | snapshot: &[u8], | ||
| 453 | ) -> std::result::Result<(), onestore::CommitError> { | ||
| 454 | assert!(self.0 == snapshot); | ||
| 455 | Ok(()) | ||
| 456 | } | ||
| 457 | } | ||
| 458 | let source = onestore::create_section("legacy.one", "AlphaOmega", "Author").unwrap(); | ||
| 459 | let store = Store::parse(&source).unwrap(); | ||
| 460 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 461 | let document = Document::parse(&index).unwrap(); | ||
| 462 | let (sid, _) = document.pages().unwrap()[0]; | ||
| 463 | let view = document.active(sid).unwrap(); | ||
| 464 | let text = *view.nodes.iter().find(|(_, node)| matches!(&node.kind, Kind::RichText { text, .. } if text == "AlphaOmega")).unwrap().0; | ||
| 465 | let directory = tempfile::tempdir().unwrap(); | ||
| 466 | let path = directory.path().join("legacy.sqlite"); | ||
| 467 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 468 | cache | ||
| 469 | .format( | ||
| 470 | &source, | ||
| 471 | sid, | ||
| 472 | text, | ||
| 473 | 0..10, | ||
| 474 | &[onestore::TextAttribute::Bold(true)], | ||
| 475 | ) | ||
| 476 | .unwrap(); | ||
| 477 | let split = onestore::ParagraphSplit::new(text, 5, "Author").unwrap(); | ||
| 478 | cache | ||
| 479 | .split(&cache.snapshot().unwrap(), sid, &split) | ||
| 480 | .unwrap(); | ||
| 481 | let before = cache.snapshot().unwrap(); | ||
| 482 | let intent = onestore::TreeEdit::delete(split.object(), "Author").unwrap(); | ||
| 483 | let deletion = cache.tree(&before, sid, &intent).unwrap().unwrap(); | ||
| 484 | let dependent = cache | ||
| 485 | .edit_text(&cache.snapshot().unwrap(), sid, text, 0..0, "Local ") | ||
| 486 | .unwrap() | ||
| 487 | .unwrap(); | ||
| 488 | let mut queue = cache.pending().unwrap(); | ||
| 489 | let Operation::Tree(edit) = &mut queue[2].operation else { | ||
| 490 | panic!() | ||
| 491 | }; | ||
| 492 | edit.observed = observe(&before, sid, &intent, true).unwrap().unwrap(); | ||
| 493 | let serialized = serde_json::to_string(&queue[2].operation).unwrap(); | ||
| 494 | let local = cache.snapshot().unwrap(); | ||
| 495 | drop(cache); | ||
| 496 | let db = Connection::open(&path).unwrap(); | ||
| 497 | db.execute( | ||
| 498 | "UPDATE edits SET operation=?1 WHERE id=?2", | ||
| 499 | params![serialized, i64::try_from(deletion).unwrap()], | ||
| 500 | ) | ||
| 501 | .unwrap(); | ||
| 502 | db.pragma_update(None, "user_version", 8).unwrap(); | ||
| 503 | db.execute_batch("ALTER TABLE attempt RENAME COLUMN revisions TO revision;") | ||
| 504 | .unwrap(); | ||
| 505 | drop(db); | ||
| 506 | let cache = Replica::open(&path).unwrap(); | ||
| 507 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 508 | assert!(cache.snapshot().unwrap() == local); | ||
| 509 | let mut server = Server(source); | ||
| 510 | for _ in 0..2 { | ||
| 511 | assert!(matches!( | ||
| 512 | cache.sync_once(&mut server).unwrap(), | ||
| 513 | Some((_, EditStatus::Published { .. })) | ||
| 514 | )); | ||
| 515 | } | ||
| 516 | assert_eq!( | ||
| 517 | cache.sync_once(&mut server).unwrap(), | ||
| 518 | Some((deletion, EditStatus::Conflict(ConflictKind::ContentChanged))) | ||
| 519 | ); | ||
| 520 | cache | ||
| 521 | .rebase_tree_conflict(deletion, &local, &server.0) | ||
| 522 | .unwrap(); | ||
| 523 | let queue = cache.pending().unwrap(); | ||
| 524 | let Operation::Tree(edit) = &queue[0].operation else { | ||
| 525 | panic!() | ||
| 526 | }; | ||
| 527 | assert!(matches!( | ||
| 528 | edit.observed.content, | ||
| 529 | Some(Content::Semantic { .. }) | ||
| 530 | )); | ||
| 531 | for id in [deletion, dependent] { | ||
| 532 | assert!( | ||
| 533 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | ||
| 534 | ); | ||
| 535 | } | ||
| 536 | assert_eq!( | ||
| 537 | paragraph(&server.0, sid, text).unwrap().as_deref(), | ||
| 538 | Some("Local Alpha") | ||
| 539 | ); | ||
| 540 | } | ||
| 541 | } | ||
crates/onestore-offline/src/worker.rs deleted-167| ... | @@ -1,167 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use std::{ | ||
| 3 | collections::hash_map::RandomState, | ||
| 4 | hash::BuildHasher, | ||
| 5 | sync::{ | ||
| 6 | Arc, | ||
| 7 | atomic::{AtomicBool, Ordering}, | ||
| 8 | mpsc::{self, SyncSender}, | ||
| 9 | }, | ||
| 10 | thread::{self, JoinHandle}, | ||
| 11 | time::Instant, | ||
| 12 | }; | ||
| 13 | |||
| 14 | pub(super) struct Signal { | ||
| 15 | stopped: AtomicBool, | ||
| 16 | sender: SyncSender<()>, | ||
| 17 | } | ||
| 18 | |||
| 19 | impl Signal { | ||
| 20 | pub(super) fn wake(&self) { | ||
| 21 | // One retained notification covers edits that arrive during network I/O. | ||
| 22 | let _ = self.sender.try_send(()); | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | /// Owns automatic reconciliation. Dropping requests cancellation without blocking. | ||
| 27 | /// The in-flight sync step finishes before ownership is released; `stop` waits for it. | ||
| 28 | pub struct SyncWorker { | ||
| 29 | signal: Arc<Signal>, | ||
| 30 | thread: Option<JoinHandle<Result<()>>>, | ||
| 31 | } | ||
| 32 | |||
| 33 | impl SyncWorker { | ||
| 34 | /// Requests a retry, for example after a network reachability change. | ||
| 35 | /// A pending contention backoff finishes before processing the notification. | ||
| 36 | pub fn wake(&self) { | ||
| 37 | self.signal.wake(); | ||
| 38 | } | ||
| 39 | |||
| 40 | /// Cancels future steps and waits for the current step and callback to finish. | ||
| 41 | /// A stopped worker leaves pending edits and uncertain attempts in the cache. | ||
| 42 | /// Call outside the worker's own callback, which cannot join its calling thread. | ||
| 43 | pub fn stop(mut self) -> Result<()> { | ||
| 44 | self.signal.stopped.store(true, Ordering::Release); | ||
| 45 | self.signal.wake(); | ||
| 46 | self.thread | ||
| 47 | .take() | ||
| 48 | .expect("Worker owns its thread") | ||
| 49 | .join() | ||
| 50 | .map_err(|_| io::Error::other("Synchronization worker panicked"))? | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | impl Drop for SyncWorker { | ||
| 55 | fn drop(&mut self) { | ||
| 56 | self.signal.stopped.store(true, Ordering::Release); | ||
| 57 | self.signal.wake(); | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | impl Replica { | ||
| 62 | /// Starts one worker, reconnecting through `connect` after transport failures. | ||
| 63 | /// Local edits wake it; `interval` controls idle polling and transport retries. | ||
| 64 | /// Contended operations returning `NotCommitted` also back off by up to one second. | ||
| 65 | /// `observe` runs on the worker after each attempt, including connection errors. | ||
| 66 | /// Cache/document errors stop the worker; inspect them through `observe` or `stop`. | ||
| 67 | /// Remote calls and callbacks must be bounded for `stop` to have bounded latency. | ||
| 68 | pub fn start_sync<R, F, O>( | ||
| 69 | self: &Arc<Self>, | ||
| 70 | interval: Duration, | ||
| 71 | mut connect: F, | ||
| 72 | mut observe: O, | ||
| 73 | ) -> io::Result<SyncWorker> | ||
| 74 | where | ||
| 75 | R: Remote + 'static, | ||
| 76 | F: FnMut() -> io::Result<R> + Send + 'static, | ||
| 77 | O: FnMut(&Result<Option<(u64, EditStatus)>>) + Send + 'static, | ||
| 78 | { | ||
| 79 | if interval.is_zero() || Instant::now().checked_add(interval).is_none() { | ||
| 80 | return Err(io::Error::new( | ||
| 81 | io::ErrorKind::InvalidInput, | ||
| 82 | "Synchronization interval must be positive and representable", | ||
| 83 | )); | ||
| 84 | } | ||
| 85 | let mut owner = self | ||
| 86 | .worker | ||
| 87 | .lock() | ||
| 88 | .map_err(|_| io::Error::other("Synchronization worker registration panicked"))?; | ||
| 89 | if owner.upgrade().is_some() { | ||
| 90 | return Err(io::ErrorKind::WouldBlock.into()); | ||
| 91 | } | ||
| 92 | let (sender, receiver) = mpsc::sync_channel(1); | ||
| 93 | let signal = Arc::new(Signal { | ||
| 94 | stopped: AtomicBool::new(false), | ||
| 95 | sender, | ||
| 96 | }); | ||
| 97 | let replica = Arc::clone(self); | ||
| 98 | let worker_signal = Arc::clone(&signal); | ||
| 99 | let thread = thread::Builder::new() | ||
| 100 | .name("onestore-sync".into()) | ||
| 101 | .spawn(move || { | ||
| 102 | let mut remote = None; | ||
| 103 | let jitter = RandomState::new(); | ||
| 104 | let mut contention = 0_u32; | ||
| 105 | while !worker_signal.stopped.load(Ordering::Acquire) { | ||
| 106 | let result = match remote.as_mut() { | ||
| 107 | Some(remote) => replica.sync_once(remote), | ||
| 108 | None => match connect() { | ||
| 109 | Ok(connected) => { | ||
| 110 | remote = Some(connected); | ||
| 111 | continue; | ||
| 112 | } | ||
| 113 | Err(error) => Err(Error::RemoteIo(error)), | ||
| 114 | }, | ||
| 115 | }; | ||
| 116 | observe(&result); | ||
| 117 | match result { | ||
| 118 | Ok(Some((_, EditStatus::Published { .. }))) => { | ||
| 119 | contention = 0; | ||
| 120 | continue; | ||
| 121 | } | ||
| 122 | Ok(None) => contention = 0, | ||
| 123 | Ok(_) => {} | ||
| 124 | Err(Error::Remote(onestore::CommitError { | ||
| 125 | state: onestore::CommitState::NotCommitted, | ||
| 126 | ref error, | ||
| 127 | })) if matches!( | ||
| 128 | error.kind(), | ||
| 129 | io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy | ||
| 130 | ) => | ||
| 131 | { | ||
| 132 | contention = contention.saturating_add(1); | ||
| 133 | let ceiling = (50_u64 << contention.min(5)).min(1000); | ||
| 134 | let until = Instant::now() | ||
| 135 | + Duration::from_millis(jitter.hash_one(contention) % ceiling); | ||
| 136 | // Local wakes must not keep competing writers in the same retry phase. | ||
| 137 | while !worker_signal.stopped.load(Ordering::Acquire) { | ||
| 138 | let Some(remaining) = until.checked_duration_since(Instant::now()) | ||
| 139 | else { | ||
| 140 | break; | ||
| 141 | }; | ||
| 142 | let _ = receiver.recv_timeout(remaining); | ||
| 143 | } | ||
| 144 | continue; | ||
| 145 | } | ||
| 146 | Err(Error::RemoteIo(ref error)) | ||
| 147 | if matches!( | ||
| 148 | error.kind(), | ||
| 149 | io::ErrorKind::WouldBlock | io::ErrorKind::ResourceBusy | ||
| 150 | ) => {} | ||
| 151 | Err(Error::RemoteIo(_) | Error::Remote(_)) => remote = None, | ||
| 152 | Err(Error::Io(ref error)) if error.kind() == io::ErrorKind::WouldBlock => {} | ||
| 153 | Err(error) => return Err(error), | ||
| 154 | } | ||
| 155 | if !worker_signal.stopped.load(Ordering::Acquire) { | ||
| 156 | let _ = receiver.recv_timeout(interval); | ||
| 157 | } | ||
| 158 | } | ||
| 159 | Ok(()) | ||
| 160 | })?; | ||
| 161 | *owner = Arc::downgrade(&signal); | ||
| 162 | Ok(SyncWorker { | ||
| 163 | signal, | ||
| 164 | thread: Some(thread), | ||
| 165 | }) | ||
| 166 | } | ||
| 167 | } | ||
crates/onestore-offline/tests/assets.rs deleted-449| ... | @@ -1,449 +0,0 @@ | ||
| 1 | use onestore_offline::{EditStatus, Error, Operation, Recovery, Replica}; | ||
| 2 | use std::{ | ||
| 3 | fs, io, | ||
| 4 | path::Path, | ||
| 5 | sync::{Barrier, mpsc}, | ||
| 6 | time::Duration, | ||
| 7 | }; | ||
| 8 | |||
| 9 | const FIXTURE: &str = "../../corpus/native-external-assets/notebook"; | ||
| 10 | const LEGACY: &str = "../../corpus/offline-v4/live.sqlite"; | ||
| 11 | |||
| 12 | fn copied_cache(root: &Path) -> Replica { | ||
| 13 | let path = root.join("cache.sqlite"); | ||
| 14 | assert!(!path.exists()); | ||
| 15 | fs::copy(LEGACY, &path).unwrap(); | ||
| 16 | Replica::open(path).unwrap() | ||
| 17 | } | ||
| 18 | |||
| 19 | fn payload(size: usize) -> (String, Vec<u8>) { | ||
| 20 | fs::read_dir(Path::new(FIXTURE).join("synthetic_onefiles")) | ||
| 21 | .unwrap() | ||
| 22 | .map(|entry| entry.unwrap().path()) | ||
| 23 | .find_map(|path| { | ||
| 24 | let bytes = fs::read(&path).unwrap(); | ||
| 25 | (bytes.len() == size) | ||
| 26 | .then(|| (path.file_name().unwrap().to_str().unwrap().into(), bytes)) | ||
| 27 | }) | ||
| 28 | .unwrap() | ||
| 29 | } | ||
| 30 | |||
| 31 | struct Payload(Option<io::Result<Vec<u8>>>); | ||
| 32 | impl onestore_notebook::Source for Payload { | ||
| 33 | fn entries(&mut self, _: &str, _: usize) -> io::Result<Vec<onestore_notebook::Entry>> { | ||
| 34 | panic!("Unexpected enumeration") | ||
| 35 | } | ||
| 36 | fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> { | ||
| 37 | self.0.take().expect("Unexpected repeated payload read") | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | #[test] | ||
| 42 | fn downloaded_media_survives_migration_reopen_and_recovery_with_the_queue_intact() { | ||
| 43 | let directory = tempfile::tempdir().unwrap(); | ||
| 44 | let original = fs::read(LEGACY).unwrap(); | ||
| 45 | let cache = copied_cache(directory.path()); | ||
| 46 | let working = cache.snapshot().unwrap(); | ||
| 47 | let remote = cache.remote_snapshot().unwrap(); | ||
| 48 | let pending = cache.pending().unwrap(); | ||
| 49 | assert_eq!( | ||
| 50 | pending.iter().map(|edit| edit.id).collect::<Vec<_>>(), | ||
| 51 | [1, 2] | ||
| 52 | ); | ||
| 53 | let uncertain = cache.status(1).unwrap(); | ||
| 54 | assert!(matches!( | ||
| 55 | uncertain, | ||
| 56 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 57 | )); | ||
| 58 | let mut source = onestore_notebook::Local::open(FIXTURE).unwrap(); | ||
| 59 | for size in [0, 1024] { | ||
| 60 | let (name, bytes) = payload(size); | ||
| 61 | assert!(cache.cached_asset(&name, size).unwrap().is_none()); | ||
| 62 | assert_eq!( | ||
| 63 | cache | ||
| 64 | .fetch_asset(&mut source, "synthetic.one", &name, size) | ||
| 65 | .unwrap(), | ||
| 66 | bytes | ||
| 67 | ); | ||
| 68 | assert_eq!( | ||
| 69 | cache | ||
| 70 | .cached_asset(&name.to_ascii_lowercase(), size) | ||
| 71 | .unwrap(), | ||
| 72 | Some(bytes) | ||
| 73 | ); | ||
| 74 | } | ||
| 75 | let summary = cache.recovery_summary().unwrap(); | ||
| 76 | assert_eq!( | ||
| 77 | (summary.cached_assets, summary.cached_asset_bytes), | ||
| 78 | (2, 1024) | ||
| 79 | ); | ||
| 80 | assert_eq!(cache.snapshot().unwrap(), working); | ||
| 81 | assert_eq!(cache.remote_snapshot().unwrap(), remote); | ||
| 82 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 83 | assert_eq!(cache.status(1).unwrap(), uncertain); | ||
| 84 | cache | ||
| 85 | .export_recovery(directory.path().join("recovery.sqlite")) | ||
| 86 | .unwrap(); | ||
| 87 | drop(cache); | ||
| 88 | let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap(); | ||
| 89 | let recovery = Recovery::open(directory.path().join("recovery.sqlite")).unwrap(); | ||
| 90 | assert_eq!(recovery.summary().unwrap(), summary); | ||
| 91 | assert_eq!(recovery.pending().unwrap(), pending); | ||
| 92 | assert_eq!(recovery.status(1).unwrap(), uncertain); | ||
| 93 | assert!(recovery.receipts().unwrap().is_empty()); | ||
| 94 | for size in [0, 1024] { | ||
| 95 | let (name, bytes) = payload(size); | ||
| 96 | assert_eq!( | ||
| 97 | cache.cached_asset(&name, size).unwrap(), | ||
| 98 | Some(bytes.clone()) | ||
| 99 | ); | ||
| 100 | assert_eq!(recovery.cached_asset(&name, size).unwrap(), Some(bytes)); | ||
| 101 | } | ||
| 102 | assert_eq!(fs::read(LEGACY).unwrap(), original); | ||
| 103 | } | ||
| 104 | |||
| 105 | #[test] | ||
| 106 | fn an_original_version_four_archive_remains_read_only_and_has_no_cached_media() { | ||
| 107 | let path = "../../corpus/offline-v4/recovery.sqlite"; | ||
| 108 | let before = fs::read(path).unwrap(); | ||
| 109 | let archive = Recovery::open(path).unwrap(); | ||
| 110 | assert_eq!(archive.pending().unwrap().len(), 2); | ||
| 111 | assert!(matches!( | ||
| 112 | archive.status(1).unwrap(), | ||
| 113 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 114 | )); | ||
| 115 | assert_eq!(archive.summary().unwrap().cached_assets, 0); | ||
| 116 | assert_eq!(archive.summary().unwrap().cached_asset_bytes, 0); | ||
| 117 | assert!(archive.cached_asset(&payload(0).0, 0).unwrap().is_none()); | ||
| 118 | drop(archive); | ||
| 119 | assert_eq!(fs::read(path).unwrap(), before); | ||
| 120 | } | ||
| 121 | |||
| 122 | #[test] | ||
| 123 | fn unsuccessful_refreshes_and_changed_identity_data_preserve_the_downloaded_payload() { | ||
| 124 | let directory = tempfile::tempdir().unwrap(); | ||
| 125 | let cache = copied_cache(directory.path()); | ||
| 126 | let (name, bytes) = payload(1024); | ||
| 127 | let mut source = Payload(Some(Ok(bytes.clone()))); | ||
| 128 | cache | ||
| 129 | .fetch_asset(&mut source, "synthetic.one", &name, 1024) | ||
| 130 | .unwrap(); | ||
| 131 | let before = fs::read(directory.path().join("cache.sqlite")).unwrap(); | ||
| 132 | let mut unavailable = Payload(Some(Err(io::ErrorKind::ConnectionReset.into()))); | ||
| 133 | assert!( | ||
| 134 | matches!(cache.fetch_asset(&mut unavailable, "synthetic.one", &name, 1024), | ||
| 135 | Err(Error::Notebook(onestore_notebook::Error::Io { error, .. })) if error.kind() == io::ErrorKind::ConnectionReset) | ||
| 136 | ); | ||
| 137 | for size in [0, 1024, 2048] { | ||
| 138 | let mut changed = Payload(Some(Ok(vec![9; size]))); | ||
| 139 | assert!(matches!( | ||
| 140 | cache.fetch_asset(&mut changed, "synthetic.one", &name, 2048), | ||
| 141 | Err(Error::AssetChanged) | ||
| 142 | )); | ||
| 143 | } | ||
| 144 | assert!( | ||
| 145 | matches!(cache.cached_asset(&name, 1023), Err(Error::Io(error)) if error.kind() == io::ErrorKind::FileTooLarge) | ||
| 146 | ); | ||
| 147 | assert_eq!(cache.cached_asset(&name, 1024).unwrap(), Some(bytes)); | ||
| 148 | assert_eq!( | ||
| 149 | fs::read(directory.path().join("cache.sqlite")).unwrap(), | ||
| 150 | before | ||
| 151 | ); | ||
| 152 | } | ||
| 153 | |||
| 154 | #[test] | ||
| 155 | fn unreferenced_payloads_are_rejected_before_io_or_local_changes() { | ||
| 156 | let directory = tempfile::tempdir().unwrap(); | ||
| 157 | let cache = copied_cache(directory.path()); | ||
| 158 | let mut unused = Payload(None); | ||
| 159 | assert!( | ||
| 160 | matches!(cache.fetch_asset(&mut unused, "synthetic.one", "00000000-0000-0000-0000-000000000001.onebin", 100), | ||
| 161 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) | ||
| 162 | ); | ||
| 163 | assert!( | ||
| 164 | cache | ||
| 165 | .fetch_asset(&mut unused, "synthetic.one", "../payload.onebin", 100) | ||
| 166 | .is_err() | ||
| 167 | ); | ||
| 168 | assert_eq!(cache.recovery_summary().unwrap().cached_assets, 0); | ||
| 169 | } | ||
| 170 | |||
| 171 | #[test] | ||
| 172 | fn download_network_wait_does_not_block_local_edits() { | ||
| 173 | struct Waiting { | ||
| 174 | entered: mpsc::Sender<()>, | ||
| 175 | released: mpsc::Receiver<()>, | ||
| 176 | bytes: Vec<u8>, | ||
| 177 | } | ||
| 178 | impl onestore_notebook::Source for Waiting { | ||
| 179 | fn entries(&mut self, _: &str, _: usize) -> io::Result<Vec<onestore_notebook::Entry>> { | ||
| 180 | panic!("Unexpected enumeration") | ||
| 181 | } | ||
| 182 | fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> { | ||
| 183 | self.entered.send(()).unwrap(); | ||
| 184 | self.released.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 185 | Ok(self.bytes.clone()) | ||
| 186 | } | ||
| 187 | } | ||
| 188 | let directory = tempfile::tempdir().unwrap(); | ||
| 189 | let cache = copied_cache(directory.path()); | ||
| 190 | let (name, bytes) = payload(1024); | ||
| 191 | let (entered, waiting) = mpsc::channel(); | ||
| 192 | let (release, released) = mpsc::channel(); | ||
| 193 | let mut source = Waiting { | ||
| 194 | entered, | ||
| 195 | released, | ||
| 196 | bytes: bytes.clone(), | ||
| 197 | }; | ||
| 198 | std::thread::scope(|scope| { | ||
| 199 | let download = scope.spawn(|| { | ||
| 200 | cache | ||
| 201 | .fetch_asset(&mut source, "synthetic.one", &name, 1024) | ||
| 202 | .unwrap() | ||
| 203 | }); | ||
| 204 | waiting.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 205 | let pending = cache.pending().unwrap(); | ||
| 206 | let Operation::Text(edit) = &pending[0].operation else { | ||
| 207 | panic!() | ||
| 208 | }; | ||
| 209 | let id = cache | ||
| 210 | .edit_text( | ||
| 211 | &cache.snapshot().unwrap(), | ||
| 212 | pending[0].space, | ||
| 213 | edit.object, | ||
| 214 | 0..0, | ||
| 215 | "during download ", | ||
| 216 | ) | ||
| 217 | .unwrap() | ||
| 218 | .unwrap(); | ||
| 219 | release.send(()).unwrap(); | ||
| 220 | assert_eq!(download.join().unwrap(), bytes); | ||
| 221 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 222 | }); | ||
| 223 | } | ||
| 224 | |||
| 225 | #[test] | ||
| 226 | fn concurrent_downloads_publish_one_immutable_cache_entry() { | ||
| 227 | let directory = tempfile::tempdir().unwrap(); | ||
| 228 | let cache = copied_cache(directory.path()); | ||
| 229 | let (name, bytes) = payload(1024); | ||
| 230 | let ready = Barrier::new(8); | ||
| 231 | std::thread::scope(|scope| { | ||
| 232 | for _ in 0..8 { | ||
| 233 | scope.spawn(|| { | ||
| 234 | let mut source = onestore_notebook::Local::open(FIXTURE).unwrap(); | ||
| 235 | ready.wait(); | ||
| 236 | assert_eq!( | ||
| 237 | cache | ||
| 238 | .fetch_asset(&mut source, "synthetic.one", &name, 1024) | ||
| 239 | .unwrap(), | ||
| 240 | bytes | ||
| 241 | ); | ||
| 242 | }); | ||
| 243 | } | ||
| 244 | }); | ||
| 245 | assert_eq!(cache.recovery_summary().unwrap().cached_assets, 1); | ||
| 246 | } | ||
| 247 | |||
| 248 | #[test] | ||
| 249 | fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() { | ||
| 250 | struct Native; | ||
| 251 | impl onestore_offline::Remote for Native { | ||
| 252 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 253 | fs::read("../../corpus/native-external-assets/native/synthetic.one") | ||
| 254 | } | ||
| 255 | fn publish(&mut self, _: &onestore::PreparedEdit<'_>) -> Result<(), onestore::CommitError> { | ||
| 256 | panic!("Unexpected publication") | ||
| 257 | } | ||
| 258 | fn confirm(&mut self, _: &[u8]) -> Result<(), onestore::CommitError> { | ||
| 259 | panic!("Unexpected confirmation") | ||
| 260 | } | ||
| 261 | } | ||
| 262 | struct Refresh<'a>(&'a Replica); | ||
| 263 | impl onestore_notebook::Source for Refresh<'_> { | ||
| 264 | fn entries(&mut self, _: &str, _: usize) -> io::Result<Vec<onestore_notebook::Entry>> { | ||
| 265 | panic!("Unexpected enumeration") | ||
| 266 | } | ||
| 267 | fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> { | ||
| 268 | assert_eq!(self.0.sync_once(&mut Native).unwrap(), None); | ||
| 269 | Ok(payload(1024).1) | ||
| 270 | } | ||
| 271 | } | ||
| 272 | let root = tempfile::tempdir().unwrap(); | ||
| 273 | let source = fs::read(Path::new(FIXTURE).join("synthetic.one")).unwrap(); | ||
| 274 | let cache = Replica::create(root.path().join("cache.sqlite"), &source).unwrap(); | ||
| 275 | let (name, _) = payload(1024); | ||
| 276 | assert!( | ||
| 277 | matches!(cache.fetch_asset(&mut Refresh(&cache), "synthetic.one", &name, 1024), | ||
| 278 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy) | ||
| 279 | ); | ||
| 280 | assert!(cache.cached_asset(&name, 1024).unwrap().is_none()); | ||
| 281 | assert_eq!( | ||
| 282 | cache.snapshot().unwrap(), | ||
| 283 | fs::read("../../corpus/native-external-assets/native/synthetic.one").unwrap() | ||
| 284 | ); | ||
| 285 | } | ||
| 286 | |||
| 287 | #[test] | ||
| 288 | fn local_failure_and_bad_cached_bytes_never_become_successful_downloads() { | ||
| 289 | let directory = tempfile::tempdir().unwrap(); | ||
| 290 | drop(copied_cache(directory.path())); | ||
| 291 | let path = directory.path().join("cache.sqlite"); | ||
| 292 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 293 | connection.execute_batch("CREATE TRIGGER fail_asset BEFORE INSERT ON assets BEGIN SELECT RAISE(ABORT,'Test asset failure'); END").unwrap(); | ||
| 294 | drop(connection); | ||
| 295 | let (name, bytes) = payload(1024); | ||
| 296 | let cache = Replica::open(&path).unwrap(); | ||
| 297 | let mut source = onestore_notebook::Local::open(FIXTURE).unwrap(); | ||
| 298 | assert!(matches!( | ||
| 299 | cache.fetch_asset(&mut source, "synthetic.one", &name, 1024), | ||
| 300 | Err(Error::Database(_)) | ||
| 301 | )); | ||
| 302 | assert!(cache.cached_asset(&name, 1024).unwrap().is_none()); | ||
| 303 | assert_eq!(cache.pending().unwrap().len(), 2); | ||
| 304 | drop(cache); | ||
| 305 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 306 | connection.execute_batch("DROP TRIGGER fail_asset").unwrap(); | ||
| 307 | drop(connection); | ||
| 308 | let cache = Replica::open(&path).unwrap(); | ||
| 309 | cache | ||
| 310 | .fetch_asset(&mut source, "synthetic.one", &name, 1024) | ||
| 311 | .unwrap(); | ||
| 312 | drop(cache); | ||
| 313 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 314 | let mut damaged = bytes; | ||
| 315 | damaged[0] ^= 1; | ||
| 316 | connection | ||
| 317 | .execute("UPDATE assets SET data=?1", [damaged]) | ||
| 318 | .unwrap(); | ||
| 319 | drop(connection); | ||
| 320 | let cache = Replica::open(&path).unwrap(); | ||
| 321 | assert!( | ||
| 322 | matches!(cache.cached_asset(&name, 1024), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidData) | ||
| 323 | ); | ||
| 324 | assert!( | ||
| 325 | matches!(cache.fetch_asset(&mut source, "synthetic.one", &name, 1024), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidData) | ||
| 326 | ); | ||
| 327 | cache | ||
| 328 | .export_recovery(directory.path().join("damaged.sqlite")) | ||
| 329 | .unwrap(); | ||
| 330 | let archive = Recovery::open(directory.path().join("damaged.sqlite")).unwrap(); | ||
| 331 | assert!(archive.cached_asset(&name, 1024).is_err()); | ||
| 332 | assert_eq!(archive.pending().unwrap().len(), 2); | ||
| 333 | } | ||
| 334 | |||
| 335 | #[test] | ||
| 336 | fn abrupt_process_exit_retains_only_completed_downloads_and_archives() { | ||
| 337 | const CHILD: &str = "ONESTORE_ASSET_EXIT_CASE"; | ||
| 338 | if let Ok(phase) = std::env::var(CHILD) { | ||
| 339 | struct ExitDuringRead; | ||
| 340 | impl onestore_notebook::Source for ExitDuringRead { | ||
| 341 | fn entries(&mut self, _: &str, _: usize) -> io::Result<Vec<onestore_notebook::Entry>> { | ||
| 342 | panic!("Unexpected enumeration") | ||
| 343 | } | ||
| 344 | fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> { | ||
| 345 | std::process::exit(83) | ||
| 346 | } | ||
| 347 | } | ||
| 348 | let root = std::env::var("ONESTORE_ASSET_EXIT_ROOT").unwrap(); | ||
| 349 | let cache = copied_cache(Path::new(&root)); | ||
| 350 | let (name, bytes) = payload(1024); | ||
| 351 | if phase == "download" { | ||
| 352 | cache | ||
| 353 | .fetch_asset(&mut ExitDuringRead, "synthetic.one", &name, bytes.len()) | ||
| 354 | .unwrap(); | ||
| 355 | panic!("Read returned after process exit"); | ||
| 356 | } | ||
| 357 | cache | ||
| 358 | .fetch_asset( | ||
| 359 | &mut onestore_notebook::Local::open(FIXTURE).unwrap(), | ||
| 360 | "synthetic.one", | ||
| 361 | &name, | ||
| 362 | bytes.len(), | ||
| 363 | ) | ||
| 364 | .unwrap(); | ||
| 365 | if phase == "archive" { | ||
| 366 | cache | ||
| 367 | .export_recovery(Path::new(&root).join("recovery.sqlite")) | ||
| 368 | .unwrap(); | ||
| 369 | } | ||
| 370 | std::process::exit(83); | ||
| 371 | } | ||
| 372 | for phase in ["download", "cached", "archive"] { | ||
| 373 | let root = tempfile::tempdir().unwrap(); | ||
| 374 | let output = std::process::Command::new(std::env::current_exe().unwrap()) | ||
| 375 | .args([ | ||
| 376 | "--exact", | ||
| 377 | "abrupt_process_exit_retains_only_completed_downloads_and_archives", | ||
| 378 | ]) | ||
| 379 | .env(CHILD, phase) | ||
| 380 | .env("ONESTORE_ASSET_EXIT_ROOT", root.path()) | ||
| 381 | .output() | ||
| 382 | .unwrap(); | ||
| 383 | assert_eq!( | ||
| 384 | output.status.code(), | ||
| 385 | Some(83), | ||
| 386 | "{}", | ||
| 387 | String::from_utf8_lossy(&output.stderr) | ||
| 388 | ); | ||
| 389 | let cache = Replica::open(root.path().join("cache.sqlite")).unwrap(); | ||
| 390 | let (name, bytes) = payload(1024); | ||
| 391 | let expected = (phase != "download").then_some(bytes); | ||
| 392 | assert_eq!(cache.cached_asset(&name, 1024).unwrap(), expected); | ||
| 393 | assert_eq!(cache.pending().unwrap().len(), 2); | ||
| 394 | assert!(matches!( | ||
| 395 | cache.status(1).unwrap(), | ||
| 396 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 397 | )); | ||
| 398 | if phase == "archive" { | ||
| 399 | let recovery = Recovery::open(root.path().join("recovery.sqlite")).unwrap(); | ||
| 400 | assert_eq!(recovery.cached_asset(&name, 1024).unwrap(), expected); | ||
| 401 | assert_eq!(recovery.pending().unwrap(), cache.pending().unwrap()); | ||
| 402 | assert_eq!(recovery.status(1).unwrap(), cache.status(1).unwrap()); | ||
| 403 | } | ||
| 404 | } | ||
| 405 | } | ||
| 406 | |||
| 407 | #[test] | ||
| 408 | #[cfg(feature = "smb")] | ||
| 409 | #[ignore = "requires a disposable Samba mirror at ONESTORE_SMB_NOTEBOOK"] | ||
| 410 | fn live_smb_downloads_survive_disconnect_and_cache_reopen() { | ||
| 411 | let root = tempfile::tempdir().unwrap(); | ||
| 412 | let cache = copied_cache(root.path()); | ||
| 413 | let client = onestore_smb::Client::connect( | ||
| 414 | &std::env::var("ONESTORE_SMB_LAB").unwrap(), | ||
| 415 | "agent", | ||
| 416 | onestore_smb::Credentials::default(), | ||
| 417 | Duration::from_secs(5), | ||
| 418 | ) | ||
| 419 | .unwrap(); | ||
| 420 | let notebook = std::env::var("ONESTORE_SMB_NOTEBOOK").unwrap(); | ||
| 421 | let mut source = onestore_notebook::Smb::new(&client, &notebook).unwrap(); | ||
| 422 | for size in [0, 771, 1024] { | ||
| 423 | let (name, bytes) = payload(size); | ||
| 424 | assert_eq!( | ||
| 425 | cache | ||
| 426 | .fetch_asset(&mut source, "synthetic.one", &name, size) | ||
| 427 | .unwrap(), | ||
| 428 | bytes | ||
| 429 | ); | ||
| 430 | } | ||
| 431 | drop(client); | ||
| 432 | cache | ||
| 433 | .export_recovery(root.path().join("recovery.sqlite")) | ||
| 434 | .unwrap(); | ||
| 435 | drop(cache); | ||
| 436 | let cache = Replica::open(root.path().join("cache.sqlite")).unwrap(); | ||
| 437 | let recovery = Recovery::open(root.path().join("recovery.sqlite")).unwrap(); | ||
| 438 | for size in [0, 771, 1024] { | ||
| 439 | let (name, bytes) = payload(size); | ||
| 440 | assert_eq!( | ||
| 441 | cache.cached_asset(&name, size).unwrap(), | ||
| 442 | Some(bytes.clone()) | ||
| 443 | ); | ||
| 444 | assert_eq!(recovery.cached_asset(&name, size).unwrap(), Some(bytes)); | ||
| 445 | } | ||
| 446 | assert_eq!(cache.pending().unwrap(), recovery.pending().unwrap()); | ||
| 447 | assert_eq!(cache.status(1).unwrap(), recovery.status(1).unwrap()); | ||
| 448 | assert_eq!(cache.recovery_summary().unwrap().cached_assets, 3); | ||
| 449 | } | ||
crates/onestore-offline/tests/cache.rs deleted-932| ... | @@ -1,932 +0,0 @@ | ||
| 1 | use onestore::{ | ||
| 2 | ExGuid, RevisionIndex, Store, | ||
| 3 | document::{Document, Kind}, | ||
| 4 | }; | ||
| 5 | use onestore_offline::{Error, Replica}; | ||
| 6 | use std::{ | ||
| 7 | collections::BTreeSet, | ||
| 8 | fs, | ||
| 9 | io::ErrorKind, | ||
| 10 | sync::Barrier, | ||
| 11 | time::{Duration, Instant}, | ||
| 12 | }; | ||
| 13 | |||
| 14 | fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { | ||
| 15 | let store = Store::parse(source).unwrap(); | ||
| 16 | assert!(store.checksum_mismatches.is_empty()); | ||
| 17 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 18 | index.validate_current().unwrap(); | ||
| 19 | let doc = Document::parse(&index).unwrap(); | ||
| 20 | doc.spaces | ||
| 21 | .iter() | ||
| 22 | .find_map(|(sid, space)| { | ||
| 23 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 24 | revision | ||
| 25 | .nodes | ||
| 26 | .iter() | ||
| 27 | .find_map(|(oid, node)| match &node.kind { | ||
| 28 | Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())), | ||
| 29 | _ => None, | ||
| 30 | }) | ||
| 31 | }) | ||
| 32 | .unwrap() | ||
| 33 | } | ||
| 34 | |||
| 35 | #[test] | ||
| 36 | fn cache_reopen_preserves_exact_images_and_intents() { | ||
| 37 | let dir = tempfile::tempdir().unwrap(); | ||
| 38 | let path = dir.path().join("section.sqlite"); | ||
| 39 | let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap(); | ||
| 40 | let replica = Replica::create(&path, &source).unwrap(); | ||
| 41 | assert_eq!(replica.snapshot().unwrap(), source); | ||
| 42 | assert!(replica.pending().unwrap().is_empty()); | ||
| 43 | let (sid, oid, _) = target(&source); | ||
| 44 | assert_eq!( | ||
| 45 | replica.edit_text(&source, sid, oid, 0..0, "").unwrap(), | ||
| 46 | None | ||
| 47 | ); | ||
| 48 | assert_eq!( | ||
| 49 | replica.edit_text(&source, sid, oid, 0..4, "café").unwrap(), | ||
| 50 | None | ||
| 51 | ); | ||
| 52 | let first = replica | ||
| 53 | .edit_text(&source, sid, oid, 5..7, "🐈 日本語") | ||
| 54 | .unwrap() | ||
| 55 | .unwrap(); | ||
| 56 | let edited = replica.snapshot().unwrap(); | ||
| 57 | assert_eq!(target(&edited).2, "café 🐈 日本語"); | ||
| 58 | let intents = replica.pending().unwrap(); | ||
| 59 | assert_eq!(intents.len(), 1); | ||
| 60 | assert_eq!(intents[0].id, first); | ||
| 61 | let onestore_offline::Operation::Text(first_edit) = &intents[0].operation else { | ||
| 62 | panic!() | ||
| 63 | }; | ||
| 64 | assert_eq!(first_edit.before, "café 🦀"); | ||
| 65 | assert_eq!(first_edit.range, 5..7); | ||
| 66 | assert_eq!(first_edit.replacement, "🐈 日本語"); | ||
| 67 | assert_eq!((intents[0].space, first_edit.object), (sid, oid)); | ||
| 68 | drop(replica); | ||
| 69 | let replica = Replica::open(&path).unwrap(); | ||
| 70 | assert_eq!(replica.snapshot().unwrap(), edited); | ||
| 71 | assert_eq!(replica.pending().unwrap(), intents); | ||
| 72 | let second = replica | ||
| 73 | .edit_text(&edited, sid, oid, 0..0, "Recovered ") | ||
| 74 | .unwrap() | ||
| 75 | .unwrap(); | ||
| 76 | assert!(second > first); | ||
| 77 | let onestore_offline::Operation::Text(second_edit) = &replica.pending().unwrap()[1].operation | ||
| 78 | else { | ||
| 79 | panic!() | ||
| 80 | }; | ||
| 81 | assert_eq!(second_edit.before, "café 🐈 日本語"); | ||
| 82 | } | ||
| 83 | |||
| 84 | #[test] | ||
| 85 | fn failed_edits_preserve_both_intent_queue_and_working_image() { | ||
| 86 | let dir = tempfile::tempdir().unwrap(); | ||
| 87 | let path = dir.path().join("section.sqlite"); | ||
| 88 | let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap(); | ||
| 89 | let replica = Replica::create(&path, &source).unwrap(); | ||
| 90 | let (sid, oid, _) = target(&source); | ||
| 91 | for (range, replacement) in [(6..7, "X"), (0..u32::MAX, "X"), (0..1, "\n")] { | ||
| 92 | assert!( | ||
| 93 | replica | ||
| 94 | .edit_text(&source, sid, oid, range, replacement) | ||
| 95 | .is_err() | ||
| 96 | ); | ||
| 97 | assert_eq!(replica.snapshot().unwrap(), source); | ||
| 98 | assert!(replica.pending().unwrap().is_empty()); | ||
| 99 | } | ||
| 100 | drop(replica); | ||
| 101 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 102 | connection.execute_batch("CREATE TRIGGER fail_image BEFORE UPDATE ON replica BEGIN SELECT RAISE(ABORT, 'Injected image update failure'); END;").unwrap(); | ||
| 103 | drop(connection); | ||
| 104 | let replica = Replica::open(&path).unwrap(); | ||
| 105 | assert!(matches!( | ||
| 106 | replica.edit_text(&source, sid, oid, 0..0, "lost? "), | ||
| 107 | Err(Error::Database(_)) | ||
| 108 | )); | ||
| 109 | drop(replica); | ||
| 110 | let replica = Replica::open(&path).unwrap(); | ||
| 111 | assert_eq!(replica.snapshot().unwrap(), source); | ||
| 112 | assert!(replica.pending().unwrap().is_empty()); | ||
| 113 | } | ||
| 114 | |||
| 115 | #[test] | ||
| 116 | fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { | ||
| 117 | use onestore_offline::{Operation, Recovery}; | ||
| 118 | |||
| 119 | let directory = tempfile::tempdir().unwrap(); | ||
| 120 | let source = onestore::create_section("recovery.one", "base", "Fixture").unwrap(); | ||
| 121 | let (space, object, _) = target(&source); | ||
| 122 | let replica = Replica::create(directory.path().join("live.sqlite"), &source).unwrap(); | ||
| 123 | let start = Barrier::new(4); | ||
| 124 | std::thread::scope(|scope| { | ||
| 125 | for writer in 0..3 { | ||
| 126 | let (replica, start) = (&replica, &start); | ||
| 127 | scope.spawn(move || { | ||
| 128 | start.wait(); | ||
| 129 | for edit in 0..20 { | ||
| 130 | loop { | ||
| 131 | let source = replica.snapshot().unwrap(); | ||
| 132 | match replica.edit_text( | ||
| 133 | &source, | ||
| 134 | space, | ||
| 135 | object, | ||
| 136 | 0..0, | ||
| 137 | &format!("[{writer}:{edit}] "), | ||
| 138 | ) { | ||
| 139 | Ok(Some(_)) => break, | ||
| 140 | Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => { | ||
| 141 | continue; | ||
| 142 | } | ||
| 143 | other => panic!("Unexpected local edit: {other:?}"), | ||
| 144 | } | ||
| 145 | } | ||
| 146 | } | ||
| 147 | }); | ||
| 148 | } | ||
| 149 | start.wait(); | ||
| 150 | for n in 0..12 { | ||
| 151 | let path = directory.path().join(format!("recovery-{n}.sqlite")); | ||
| 152 | replica.export_recovery(&path).unwrap(); | ||
| 153 | let archive = Recovery::open(path).unwrap(); | ||
| 154 | let pending = archive.pending().unwrap(); | ||
| 155 | let mut expected = String::new(); | ||
| 156 | for edit in pending.iter().rev() { | ||
| 157 | let Operation::Text(edit) = &edit.operation else { | ||
| 158 | panic!() | ||
| 159 | }; | ||
| 160 | assert_eq!(edit.range, 0..0); | ||
| 161 | expected.push_str(&edit.replacement); | ||
| 162 | } | ||
| 163 | expected.push_str("base"); | ||
| 164 | assert_eq!(target(&archive.snapshot().unwrap()).2, expected); | ||
| 165 | assert_eq!(archive.remote_snapshot().unwrap(), source); | ||
| 166 | assert_eq!( | ||
| 167 | archive.summary().unwrap().queued_edits, | ||
| 168 | pending.len() as u64 | ||
| 169 | ); | ||
| 170 | assert!(archive.receipts().unwrap().is_empty()); | ||
| 171 | } | ||
| 172 | }); | ||
| 173 | assert_eq!(replica.pending().unwrap().len(), 60); | ||
| 174 | } | ||
| 175 | |||
| 176 | #[test] | ||
| 177 | fn ownership_and_foreign_file_rejection_preserve_existing_data() { | ||
| 178 | let dir = tempfile::tempdir().unwrap(); | ||
| 179 | let path = dir.path().join("section.sqlite"); | ||
| 180 | assert!(Replica::open(&path).is_err()); | ||
| 181 | assert!(!path.exists()); | ||
| 182 | assert!(Replica::create(&path, b"invalid").is_err()); | ||
| 183 | assert!(!path.exists()); | ||
| 184 | let source = onestore::create_section("section.one", "Owned", "Fixture").unwrap(); | ||
| 185 | let replica = Replica::create(&path, &source).unwrap(); | ||
| 186 | for _ in 0..3 { | ||
| 187 | assert!( | ||
| 188 | matches!(Replica::open(&path), Err(Error::Database(error)) if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy)) | ||
| 189 | ); | ||
| 190 | assert!( | ||
| 191 | matches!(Replica::create(&path, &source), Err(Error::Io(error)) if error.kind() == ErrorKind::AlreadyExists) | ||
| 192 | ); | ||
| 193 | assert_eq!(replica.snapshot().unwrap(), source); | ||
| 194 | } | ||
| 195 | drop(replica); | ||
| 196 | for sql in [ | ||
| 197 | "PRAGMA application_id=0", | ||
| 198 | "PRAGMA application_id=1330529615; PRAGMA user_version=99", | ||
| 199 | ] { | ||
| 200 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 201 | connection.execute_batch(sql).unwrap(); | ||
| 202 | drop(connection); | ||
| 203 | let before = fs::read(&path).unwrap(); | ||
| 204 | assert!(Replica::open(&path).is_err()); | ||
| 205 | assert_eq!(fs::read(&path).unwrap(), before); | ||
| 206 | } | ||
| 207 | let foreign = dir.path().join("foreign.sqlite"); | ||
| 208 | let connection = rusqlite::Connection::open(&foreign).unwrap(); | ||
| 209 | connection | ||
| 210 | .execute_batch( | ||
| 211 | "CREATE TABLE unrelated (value TEXT); INSERT INTO unrelated VALUES ('preserve');", | ||
| 212 | ) | ||
| 213 | .unwrap(); | ||
| 214 | drop(connection); | ||
| 215 | let before = fs::read(&foreign).unwrap(); | ||
| 216 | assert!(Replica::open(&foreign).is_err()); | ||
| 217 | assert_eq!(fs::read(&foreign).unwrap(), before); | ||
| 218 | let incomplete = dir.path().join("incomplete.sqlite"); | ||
| 219 | fs::write(&incomplete, []).unwrap(); | ||
| 220 | assert!(Replica::open(&incomplete).is_err()); | ||
| 221 | assert_eq!(fs::read(&incomplete).unwrap(), b""); | ||
| 222 | } | ||
| 223 | |||
| 224 | #[test] | ||
| 225 | fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement() { | ||
| 226 | let dir = tempfile::tempdir().unwrap(); | ||
| 227 | let path = dir.path().join("section.sqlite"); | ||
| 228 | let source = onestore::create_section("section.one", "Shared café 🦀", "Fixture").unwrap(); | ||
| 229 | let replica = Replica::create(&path, &source).unwrap(); | ||
| 230 | let (sid, oid, _) = target(&source); | ||
| 231 | let barrier = Barrier::new(12); | ||
| 232 | let deadline = Instant::now() + Duration::from_secs(60); | ||
| 233 | let outcomes = std::thread::scope(|scope| { | ||
| 234 | let handles: Vec<_> = (0..12) | ||
| 235 | .map(|writer| { | ||
| 236 | let (replica, source, barrier) = (&replica, &source, &barrier); | ||
| 237 | scope.spawn(move || { | ||
| 238 | barrier.wait(); | ||
| 239 | let first = | ||
| 240 | replica.edit_text(source, sid, oid, 0..0, &format!("[initial-{writer}] ")); | ||
| 241 | let mut ids = Vec::new(); | ||
| 242 | let first_won = match first { | ||
| 243 | Ok(Some(id)) => { | ||
| 244 | ids.push(id); | ||
| 245 | true | ||
| 246 | } | ||
| 247 | Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => false, | ||
| 248 | other => panic!("Unexpected first edit: {other:?}"), | ||
| 249 | }; | ||
| 250 | for edit in 0..20 { | ||
| 251 | loop { | ||
| 252 | assert!( | ||
| 253 | Instant::now() < deadline, | ||
| 254 | "Writer {writer} stopped progressing at {edit}" | ||
| 255 | ); | ||
| 256 | let source = replica.snapshot().unwrap(); | ||
| 257 | match replica.edit_text( | ||
| 258 | &source, | ||
| 259 | sid, | ||
| 260 | oid, | ||
| 261 | 0..0, | ||
| 262 | &format!("[{writer}-{edit}] "), | ||
| 263 | ) { | ||
| 264 | Ok(Some(id)) => { | ||
| 265 | ids.push(id); | ||
| 266 | break; | ||
| 267 | } | ||
| 268 | Err(Error::Io(error)) | ||
| 269 | if error.kind() == ErrorKind::ResourceBusy => {} | ||
| 270 | other => panic!("Unexpected edit: {other:?}"), | ||
| 271 | } | ||
| 272 | } | ||
| 273 | } | ||
| 274 | (first_won, ids) | ||
| 275 | }) | ||
| 276 | }) | ||
| 277 | .collect(); | ||
| 278 | handles | ||
| 279 | .into_iter() | ||
| 280 | .map(|handle| handle.join().unwrap()) | ||
| 281 | .collect::<Vec<_>>() | ||
| 282 | }); | ||
| 283 | assert_eq!(outcomes.iter().filter(|(won, _)| *won).count(), 1); | ||
| 284 | let ids: BTreeSet<_> = outcomes.into_iter().flat_map(|(_, ids)| ids).collect(); | ||
| 285 | assert_eq!(ids.len(), 241); | ||
| 286 | let final_bytes = replica.snapshot().unwrap(); | ||
| 287 | let content = target(&final_bytes).2; | ||
| 288 | let mut expected = "Shared café 🦀".to_owned(); | ||
| 289 | for pending in replica.pending().unwrap() { | ||
| 290 | let onestore_offline::Operation::Text(edit) = pending.operation else { | ||
| 291 | panic!() | ||
| 292 | }; | ||
| 293 | assert_eq!(edit.before, expected); | ||
| 294 | assert_eq!(edit.range, 0..0); | ||
| 295 | expected.insert_str(0, &edit.replacement); | ||
| 296 | } | ||
| 297 | assert_eq!(content, expected); | ||
| 298 | for writer in 0..12 { | ||
| 299 | for edit in 0..20 { | ||
| 300 | assert_eq!(content.matches(&format!("[{writer}-{edit}] ")).count(), 1); | ||
| 301 | } | ||
| 302 | } | ||
| 303 | assert_eq!( | ||
| 304 | replica | ||
| 305 | .pending() | ||
| 306 | .unwrap() | ||
| 307 | .iter() | ||
| 308 | .map(|edit| edit.id) | ||
| 309 | .collect::<BTreeSet<_>>(), | ||
| 310 | ids | ||
| 311 | ); | ||
| 312 | assert!( | ||
| 313 | matches!(replica.edit_text(&source, sid, oid, 0..0, ""), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy) | ||
| 314 | ); | ||
| 315 | drop(replica); | ||
| 316 | let reopened = Replica::open(&path).unwrap(); | ||
| 317 | assert_eq!(reopened.snapshot().unwrap(), final_bytes); | ||
| 318 | assert_eq!(reopened.pending().unwrap().len(), 241); | ||
| 319 | } | ||
| 320 | |||
| 321 | #[test] | ||
| 322 | fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { | ||
| 323 | let dir = tempfile::tempdir().unwrap(); | ||
| 324 | let path = dir.path().join("model.sqlite"); | ||
| 325 | let mut text = "ab🚀ab🦀 é repeated repeated".to_owned(); | ||
| 326 | let mut source = onestore::create_section("model.one", &text, "Fixture").unwrap(); | ||
| 327 | let mut replica = Replica::create(&path, &source).unwrap(); | ||
| 328 | let (space, object, _) = target(&source); | ||
| 329 | let mut random = 911_u64; | ||
| 330 | let mut next = || { | ||
| 331 | random ^= random << 13; | ||
| 332 | random ^= random >> 7; | ||
| 333 | random ^= random << 17; | ||
| 334 | random | ||
| 335 | }; | ||
| 336 | let mut intents = Vec::new(); | ||
| 337 | for step in 0..1024 { | ||
| 338 | let boundaries: Vec<_> = text | ||
| 339 | .char_indices() | ||
| 340 | .map(|(at, _)| at) | ||
| 341 | .chain([text.len()]) | ||
| 342 | .collect(); | ||
| 343 | let first = boundaries[next() as usize % boundaries.len()]; | ||
| 344 | let last = boundaries[next() as usize % boundaries.len()]; | ||
| 345 | let bytes = first.min(last)..first.max(last); | ||
| 346 | let range = u32::try_from(text[..bytes.start].encode_utf16().count()).unwrap() | ||
| 347 | ..u32::try_from(text[..bytes.end].encode_utf16().count()).unwrap(); | ||
| 348 | let replacement = ["", "🐈", "日本語", "repeated", "é", "ab🦀ab"][next() as usize % 6]; | ||
| 349 | let mut expected = text.clone(); | ||
| 350 | expected.replace_range(bytes, replacement); | ||
| 351 | let acknowledgement = replica | ||
| 352 | .edit_text(&source, space, object, range.clone(), replacement) | ||
| 353 | .unwrap(); | ||
| 354 | if let Some(id) = acknowledgement { | ||
| 355 | assert_ne!(text, expected); | ||
| 356 | assert!( | ||
| 357 | intents | ||
| 358 | .last() | ||
| 359 | .is_none_or(|edit: &onestore_offline::PendingEdit| edit.id < id) | ||
| 360 | ); | ||
| 361 | intents.push(onestore_offline::PendingEdit { | ||
| 362 | id, | ||
| 363 | space, | ||
| 364 | operation: onestore_offline::Operation::Text(onestore_offline::TextEdit { | ||
| 365 | object, | ||
| 366 | before: text, | ||
| 367 | range, | ||
| 368 | replacement: replacement.into(), | ||
| 369 | }), | ||
| 370 | }); | ||
| 371 | assert!( | ||
| 372 | matches!(replica.edit_text(&source, space, object, 0..0, "stale"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy) | ||
| 373 | ); | ||
| 374 | } else { | ||
| 375 | assert_eq!(text, expected); | ||
| 376 | } | ||
| 377 | text = expected; | ||
| 378 | source = replica.snapshot().unwrap(); | ||
| 379 | assert_eq!(target(&source).2, text, "seed 911, step {step}"); | ||
| 380 | if step % 37 == 0 { | ||
| 381 | drop(replica); | ||
| 382 | replica = Replica::open(&path).unwrap(); | ||
| 383 | assert_eq!(replica.snapshot().unwrap(), source); | ||
| 384 | assert_eq!(replica.pending().unwrap(), intents); | ||
| 385 | } | ||
| 386 | } | ||
| 387 | assert!( | ||
| 388 | intents.len() > 512, | ||
| 389 | "The history checkpoint boundary was not exercised" | ||
| 390 | ); | ||
| 391 | drop(replica); | ||
| 392 | let replica = Replica::open(&path).unwrap(); | ||
| 393 | assert_eq!(replica.pending().unwrap(), intents); | ||
| 394 | assert_eq!(replica.snapshot().unwrap(), source); | ||
| 395 | } | ||
| 396 | |||
| 397 | #[test] | ||
| 398 | #[ignore = "migrates a fresh copy of a retained version-two or version-three cache"] | ||
| 399 | fn migrate_retained_cache_copy() { | ||
| 400 | use onestore_offline::{EditStatus, Operation, PendingEdit, TextEdit}; | ||
| 401 | let source = std::path::PathBuf::from(std::env::var_os("ONESTORE_MIGRATION_SOURCE").unwrap()); | ||
| 402 | let output = std::path::PathBuf::from(std::env::var_os("ONESTORE_MIGRATION_OUTPUT").unwrap()); | ||
| 403 | assert!(source.is_absolute() && output.is_absolute()); | ||
| 404 | let mut original = fs::File::open(&source).unwrap(); | ||
| 405 | let mut destination = fs::OpenOptions::new() | ||
| 406 | .write(true) | ||
| 407 | .create_new(true) | ||
| 408 | .open(&output) | ||
| 409 | .unwrap(); | ||
| 410 | std::io::copy(&mut original, &mut destination).unwrap(); | ||
| 411 | destination.sync_all().unwrap(); | ||
| 412 | drop(destination); | ||
| 413 | let db = rusqlite::Connection::open(&output).unwrap(); | ||
| 414 | let version = db | ||
| 415 | .pragma_query_value(None, "user_version", |r| r.get::<_, u32>(0)) | ||
| 416 | .unwrap(); | ||
| 417 | assert!(matches!(version, 2 | 3)); | ||
| 418 | if std::env::var_os("ONESTORE_MIGRATION_ROLLBACK").is_some() { | ||
| 419 | assert_eq!(version, 2); | ||
| 420 | db.pragma_update(None, "foreign_keys", false).unwrap(); | ||
| 421 | db.execute( | ||
| 422 | "INSERT INTO attempt VALUES (1,999999,'{00000001-0000-0000-0000-000000000000},1')", | ||
| 423 | [], | ||
| 424 | ) | ||
| 425 | .unwrap(); | ||
| 426 | drop(db); | ||
| 427 | let before = fs::read(&output).unwrap(); | ||
| 428 | assert!( | ||
| 429 | matches!(Replica::open(&output), Err(Error::Database(rusqlite::Error::SqliteFailure(error, _))) if error.extended_code == 787) | ||
| 430 | ); | ||
| 431 | assert_eq!(fs::read(&output).unwrap(), before); | ||
| 432 | let db = rusqlite::Connection::open(&output).unwrap(); | ||
| 433 | assert_eq!( | ||
| 434 | db.pragma_query_value(None, "user_version", |r| r.get::<_, u32>(0)) | ||
| 435 | .unwrap(), | ||
| 436 | 2 | ||
| 437 | ); | ||
| 438 | println!("migration: rollback preserved {} bytes", before.len()); | ||
| 439 | return; | ||
| 440 | } | ||
| 441 | if std::env::var_os("ONESTORE_MIGRATION_CONFLICT").is_some() { | ||
| 442 | db.execute("INSERT INTO conflicts SELECT min(id),0 FROM edits", []) | ||
| 443 | .unwrap(); | ||
| 444 | } | ||
| 445 | let (base, working): (Vec<u8>, Vec<u8>) = db | ||
| 446 | .query_row("SELECT base,working FROM replica", [], |r| { | ||
| 447 | Ok((r.get(0)?, r.get(1)?)) | ||
| 448 | }) | ||
| 449 | .unwrap(); | ||
| 450 | let sequence: i64 = db | ||
| 451 | .query_row( | ||
| 452 | "SELECT seq FROM sqlite_sequence WHERE name='edits'", | ||
| 453 | [], | ||
| 454 | |r| r.get(0), | ||
| 455 | ) | ||
| 456 | .unwrap(); | ||
| 457 | let pending: Vec<PendingEdit> = | ||
| 458 | if version == 2 { | ||
| 459 | let mut query = db | ||
| 460 | .prepare("SELECT id,space,object,before_text,start,end,replacement FROM edits ORDER BY id") | ||
| 461 | .unwrap(); | ||
| 462 | let pending: Vec<PendingEdit> = query | ||
| 463 | .query_map([], |r| { | ||
| 464 | Ok(PendingEdit { | ||
| 465 | id: u64::try_from(r.get::<_, i64>(0)?).unwrap(), | ||
| 466 | space: r.get::<_, String>(1)?.parse().unwrap(), | ||
| 467 | operation: Operation::Text(TextEdit { | ||
| 468 | object: r.get::<_, String>(2)?.parse().unwrap(), | ||
| 469 | before: r.get(3)?, | ||
| 470 | range: r.get(4)?..r.get(5)?, | ||
| 471 | replacement: r.get(6)?, | ||
| 472 | }), | ||
| 473 | }) | ||
| 474 | }) | ||
| 475 | .unwrap() | ||
| 476 | .collect::<Result<_, _>>() | ||
| 477 | .unwrap(); | ||
| 478 | drop(query); | ||
| 479 | pending | ||
| 480 | } else { | ||
| 481 | let mut query = db | ||
| 482 | .prepare("SELECT id,space,operation FROM edits ORDER BY id") | ||
| 483 | .unwrap(); | ||
| 484 | query | ||
| 485 | .query_map([], |r| { | ||
| 486 | Ok(PendingEdit { | ||
| 487 | id: u64::try_from(r.get::<_, i64>(0)?).unwrap(), | ||
| 488 | space: r.get::<_, String>(1)?.parse().unwrap(), | ||
| 489 | operation: serde_json::from_str(&r.get::<_, String>(2)?).unwrap(), | ||
| 490 | }) | ||
| 491 | }) | ||
| 492 | .unwrap() | ||
| 493 | .collect::<Result<_, _>>() | ||
| 494 | .unwrap() | ||
| 495 | }; | ||
| 496 | let mut states = std::collections::BTreeMap::new(); | ||
| 497 | for edit in &pending { | ||
| 498 | states.insert(edit.id, EditStatus::Pending); | ||
| 499 | } | ||
| 500 | for table in ["receipts", "attempt"] { | ||
| 501 | let mut query = db | ||
| 502 | .prepare(&format!("SELECT edit_id,revision FROM {table}")) | ||
| 503 | .unwrap(); | ||
| 504 | for row in query | ||
| 505 | .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))) | ||
| 506 | .unwrap() | ||
| 507 | { | ||
| 508 | let (id, rid) = row.unwrap(); | ||
| 509 | let revision = rid.parse().unwrap(); | ||
| 510 | states.insert( | ||
| 511 | u64::try_from(id).unwrap(), | ||
| 512 | if table == "receipts" { | ||
| 513 | EditStatus::Published { revision } | ||
| 514 | } else { | ||
| 515 | EditStatus::AwaitingConfirmation { revision } | ||
| 516 | }, | ||
| 517 | ); | ||
| 518 | } | ||
| 519 | } | ||
| 520 | let mut query = db.prepare("SELECT edit_id,kind FROM conflicts").unwrap(); | ||
| 521 | for row in query | ||
| 522 | .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, u32>(1)?))) | ||
| 523 | .unwrap() | ||
| 524 | { | ||
| 525 | let (id, kind) = row.unwrap(); | ||
| 526 | assert_eq!(kind, 0); | ||
| 527 | states.insert( | ||
| 528 | u64::try_from(id).unwrap(), | ||
| 529 | EditStatus::Conflict(onestore_offline::ConflictKind::TextChanged), | ||
| 530 | ); | ||
| 531 | } | ||
| 532 | drop(query); | ||
| 533 | drop(db); | ||
| 534 | let cache = Replica::open(&output).unwrap(); | ||
| 535 | assert_eq!(cache.snapshot().unwrap(), working); | ||
| 536 | assert_eq!(cache.remote_snapshot().unwrap(), base); | ||
| 537 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 538 | for (id, status) in &states { | ||
| 539 | assert_eq!(cache.status(*id).unwrap(), Some(*status)); | ||
| 540 | } | ||
| 541 | drop(cache); | ||
| 542 | let cache = Replica::open(&output).unwrap(); | ||
| 543 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 544 | let store = Store::parse(&working).unwrap(); | ||
| 545 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 546 | let doc = Document::parse(&index).unwrap(); | ||
| 547 | let (sid, page) = doc.pages().unwrap()[0]; | ||
| 548 | let insertion = onestore::Insertion::outline( | ||
| 549 | page, | ||
| 550 | 144.0, | ||
| 551 | 720.0, | ||
| 552 | "After cache migration", | ||
| 553 | "Migration author", | ||
| 554 | ) | ||
| 555 | .unwrap(); | ||
| 556 | let next = cache.insert(&working, sid, &insertion).unwrap().unwrap(); | ||
| 557 | assert_eq!(next, u64::try_from(sequence + 1).unwrap()); | ||
| 558 | let updated = cache.snapshot().unwrap(); | ||
| 559 | drop(cache); | ||
| 560 | let cache = Replica::open(&output).unwrap(); | ||
| 561 | assert_eq!(cache.snapshot().unwrap(), updated); | ||
| 562 | assert_eq!( | ||
| 563 | cache.pending().unwrap().last().unwrap().operation, | ||
| 564 | Operation::Insert(insertion) | ||
| 565 | ); | ||
| 566 | println!( | ||
| 567 | "migration: {}", | ||
| 568 | serde_json::json!({"pending":pending.len(),"retained_statuses":states.len(),"next_id":next,"base_bytes":base.len(),"working_bytes":working.len()}) | ||
| 569 | ); | ||
| 570 | } | ||
| 571 | |||
| 572 | #[test] | ||
| 573 | fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { | ||
| 574 | use onestore::Insertion; | ||
| 575 | let directory = tempfile::tempdir().unwrap(); | ||
| 576 | let path = directory.path().join("parallel.sqlite"); | ||
| 577 | let source = onestore::create_section("parallel.one", "Original", "Author").unwrap(); | ||
| 578 | let store = Store::parse(&source).unwrap(); | ||
| 579 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 580 | let doc = Document::parse(&index).unwrap(); | ||
| 581 | let (sid, page) = doc.pages().unwrap()[0]; | ||
| 582 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 583 | let barrier = Barrier::new(12); | ||
| 584 | let deadline = Instant::now() + Duration::from_secs(90); | ||
| 585 | let all = std::thread::scope(|scope| { | ||
| 586 | let handles: Vec<_> = (0..12) | ||
| 587 | .map(|client| { | ||
| 588 | let (cache, barrier) = (&cache, &barrier); | ||
| 589 | scope.spawn(move || { | ||
| 590 | let outline = Insertion::outline( | ||
| 591 | page, | ||
| 592 | 72.0, | ||
| 593 | 144.0 + client as f32 * 72.0, | ||
| 594 | &format!("Client {client}"), | ||
| 595 | "Author", | ||
| 596 | ) | ||
| 597 | .unwrap(); | ||
| 598 | let mut ids = Vec::new(); | ||
| 599 | let mut objects = Vec::new(); | ||
| 600 | barrier.wait(); | ||
| 601 | for sequence in 0..4 { | ||
| 602 | let insertion = if sequence == 0 { | ||
| 603 | outline.clone() | ||
| 604 | } else { | ||
| 605 | Insertion::paragraph( | ||
| 606 | outline.object(), | ||
| 607 | None, | ||
| 608 | &format!("Paragraph {client}:{sequence}"), | ||
| 609 | "Author", | ||
| 610 | ) | ||
| 611 | .unwrap() | ||
| 612 | } | ||
| 613 | .with_formatting(0..6, &[onestore::TextAttribute::Italic(true)]) | ||
| 614 | .unwrap(); | ||
| 615 | loop { | ||
| 616 | assert!( | ||
| 617 | Instant::now() < deadline, | ||
| 618 | "Client {client} stopped at insertion {sequence}" | ||
| 619 | ); | ||
| 620 | let snapshot = cache.snapshot().unwrap(); | ||
| 621 | match cache.insert(&snapshot, sid, &insertion) { | ||
| 622 | Ok(Some(id)) => { | ||
| 623 | ids.push(id); | ||
| 624 | objects.push(insertion.text_object()); | ||
| 625 | break; | ||
| 626 | } | ||
| 627 | Err(Error::Io(e)) if e.kind() == ErrorKind::ResourceBusy => {} | ||
| 628 | other => panic!("{other:?}"), | ||
| 629 | } | ||
| 630 | } | ||
| 631 | if sequence == 0 { | ||
| 632 | continue; | ||
| 633 | } | ||
| 634 | loop { | ||
| 635 | assert!( | ||
| 636 | Instant::now() < deadline, | ||
| 637 | "Client {client} stopped at text {sequence}" | ||
| 638 | ); | ||
| 639 | let snapshot = cache.snapshot().unwrap(); | ||
| 640 | match cache.edit_text( | ||
| 641 | &snapshot, | ||
| 642 | sid, | ||
| 643 | insertion.text_object(), | ||
| 644 | 0..0, | ||
| 645 | "Edited ", | ||
| 646 | ) { | ||
| 647 | Ok(Some(id)) => { | ||
| 648 | ids.push(id); | ||
| 649 | break; | ||
| 650 | } | ||
| 651 | Err(Error::Io(e)) if e.kind() == ErrorKind::ResourceBusy => {} | ||
| 652 | other => panic!("{other:?}"), | ||
| 653 | } | ||
| 654 | } | ||
| 655 | } | ||
| 656 | (ids, objects) | ||
| 657 | }) | ||
| 658 | }) | ||
| 659 | .collect(); | ||
| 660 | handles | ||
| 661 | .into_iter() | ||
| 662 | .map(|h| h.join().unwrap()) | ||
| 663 | .collect::<Vec<_>>() | ||
| 664 | }); | ||
| 665 | let ids: BTreeSet<_> = all | ||
| 666 | .iter() | ||
| 667 | .flat_map(|(ids, _)| ids.iter().copied()) | ||
| 668 | .collect(); | ||
| 669 | assert_eq!(ids.len(), 84); | ||
| 670 | let snapshot = cache.snapshot().unwrap(); | ||
| 671 | drop(cache); | ||
| 672 | let cache = Replica::open(&path).unwrap(); | ||
| 673 | assert_eq!(cache.snapshot().unwrap(), snapshot); | ||
| 674 | assert_eq!( | ||
| 675 | cache | ||
| 676 | .pending() | ||
| 677 | .unwrap() | ||
| 678 | .iter() | ||
| 679 | .map(|e| e.id) | ||
| 680 | .collect::<BTreeSet<_>>(), | ||
| 681 | ids | ||
| 682 | ); | ||
| 683 | let store = Store::parse(&snapshot).unwrap(); | ||
| 684 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 685 | let doc = Document::parse(&index).unwrap(); | ||
| 686 | let s = &doc.spaces[&sid]; | ||
| 687 | let v = &s.revisions[&s.contexts[&ExGuid::default()]]; | ||
| 688 | for (client, (_, objects)) in all.iter().enumerate() { | ||
| 689 | for (sequence, id) in objects.iter().enumerate() { | ||
| 690 | let wanted = if sequence == 0 { | ||
| 691 | format!("Client {client}") | ||
| 692 | } else { | ||
| 693 | format!("Edited Paragraph {client}:{sequence}") | ||
| 694 | }; | ||
| 695 | assert!(matches!(&v.nodes[id].kind,Kind::RichText{text,..} if *text==wanted)); | ||
| 696 | let runs = v.text_runs(*id).unwrap(); | ||
| 697 | assert_eq!(runs[0].format.italic, Some(true)); | ||
| 698 | assert_eq!( | ||
| 699 | runs[0].text, | ||
| 700 | if sequence == 0 { | ||
| 701 | "Client" | ||
| 702 | } else { | ||
| 703 | "Edited Paragr" | ||
| 704 | } | ||
| 705 | ); | ||
| 706 | assert!(runs[1..].iter().all(|run| run.format.italic != Some(true))); | ||
| 707 | } | ||
| 708 | } | ||
| 709 | } | ||
| 710 | |||
| 711 | #[test] | ||
| 712 | fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { | ||
| 713 | for operation in ["Text", "Insert", "Format", "Split", "Join", "Outline"] { | ||
| 714 | let directory = tempfile::tempdir().unwrap(); | ||
| 715 | let path = directory.path().join("unknown.sqlite"); | ||
| 716 | let mut source = onestore::create_section("unknown.one", "Original", "Author").unwrap(); | ||
| 717 | let (sid, oid, _) = target(&source); | ||
| 718 | let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap(); | ||
| 719 | if operation == "Join" { | ||
| 720 | source = onestore::PreparedEdit::split(&source, sid, &split) | ||
| 721 | .unwrap() | ||
| 722 | .as_bytes() | ||
| 723 | .to_vec(); | ||
| 724 | } | ||
| 725 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 726 | if operation == "Insert" { | ||
| 727 | let store = Store::parse(&source).unwrap(); | ||
| 728 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 729 | let doc = Document::parse(&index).unwrap(); | ||
| 730 | let (_, page) = doc.pages().unwrap()[0]; | ||
| 731 | let insertion = | ||
| 732 | onestore::Insertion::outline(page, 144.0, 144.0, "Inserted", "Author").unwrap(); | ||
| 733 | cache.insert(&source, sid, &insertion).unwrap(); | ||
| 734 | } else if operation == "Outline" { | ||
| 735 | let store = Store::parse(&source).unwrap(); | ||
| 736 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 737 | let doc = Document::parse(&index).unwrap(); | ||
| 738 | let space = &doc.spaces[&sid]; | ||
| 739 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 740 | let paragraph = *view | ||
| 741 | .nodes | ||
| 742 | .iter() | ||
| 743 | .find(|(_, node)| node.content == [oid]) | ||
| 744 | .unwrap() | ||
| 745 | .0; | ||
| 746 | cache | ||
| 747 | .outline( | ||
| 748 | &source, | ||
| 749 | sid, | ||
| 750 | paragraph, | ||
| 751 | onestore::OutlineEdit::Collapsed(true), | ||
| 752 | ) | ||
| 753 | .unwrap(); | ||
| 754 | } else if operation == "Text" { | ||
| 755 | cache.edit_text(&source, sid, oid, 0..0, "New ").unwrap(); | ||
| 756 | } else if operation == "Split" { | ||
| 757 | cache.split(&source, sid, &split).unwrap(); | ||
| 758 | } else if operation == "Join" { | ||
| 759 | cache | ||
| 760 | .join( | ||
| 761 | &source, | ||
| 762 | sid, | ||
| 763 | &onestore::ParagraphJoin::new(oid, split.text_object(), "Author").unwrap(), | ||
| 764 | ) | ||
| 765 | .unwrap(); | ||
| 766 | } else { | ||
| 767 | cache | ||
| 768 | .format( | ||
| 769 | &source, | ||
| 770 | sid, | ||
| 771 | oid, | ||
| 772 | 0..4, | ||
| 773 | &[onestore::TextAttribute::Bold(true)], | ||
| 774 | ) | ||
| 775 | .unwrap(); | ||
| 776 | } | ||
| 777 | drop(cache); | ||
| 778 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 779 | let encoded: String = db | ||
| 780 | .query_row("SELECT operation FROM edits", [], |r| r.get(0)) | ||
| 781 | .unwrap(); | ||
| 782 | let mut value: serde_json::Value = serde_json::from_str(&encoded).unwrap(); | ||
| 783 | value[operation]["future_option"] = true.into(); | ||
| 784 | db.execute("UPDATE edits SET operation=?1", [value.to_string()]) | ||
| 785 | .unwrap(); | ||
| 786 | if matches!(operation, "Text" | "Insert") { | ||
| 787 | db.execute_batch("ALTER TABLE attempt RENAME COLUMN revisions TO revision; DROP TABLE assets; DROP TABLE conflicts; CREATE TABLE conflicts (edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 2)) STRICT; PRAGMA user_version=3;").unwrap(); | ||
| 788 | } | ||
| 789 | drop(db); | ||
| 790 | let before = fs::read(&path).unwrap(); | ||
| 791 | assert!( | ||
| 792 | matches!(Replica::open(&path),Err(Error::Io(error))if error.kind()==ErrorKind::InvalidData) | ||
| 793 | ); | ||
| 794 | assert_eq!(fs::read(&path).unwrap(), before); | ||
| 795 | } | ||
| 796 | } | ||
| 797 | |||
| 798 | #[test] | ||
| 799 | fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_conflicts() { | ||
| 800 | for (version, ceiling) in [(5, 3), (6, 4), (7, 5), (8, 6), (9, 6)] { | ||
| 801 | use onestore_offline::{ConflictKind, EditStatus, Recovery}; | ||
| 802 | use sha2::{Digest, Sha256}; | ||
| 803 | let directory = tempfile::tempdir().unwrap(); | ||
| 804 | let path = directory.path().join("previous.sqlite"); | ||
| 805 | let source = onestore::create_section("migration.one", "Original", "Author").unwrap(); | ||
| 806 | let (sid, oid, _) = target(&source); | ||
| 807 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 808 | let first = cache | ||
| 809 | .edit_text(&source, sid, oid, 0..0, "New ") | ||
| 810 | .unwrap() | ||
| 811 | .unwrap(); | ||
| 812 | let second = cache | ||
| 813 | .format( | ||
| 814 | &cache.snapshot().unwrap(), | ||
| 815 | sid, | ||
| 816 | oid, | ||
| 817 | 0..3, | ||
| 818 | &[onestore::TextAttribute::Bold(true)], | ||
| 819 | ) | ||
| 820 | .unwrap() | ||
| 821 | .unwrap(); | ||
| 822 | let queue = cache.pending().unwrap(); | ||
| 823 | let local = cache.snapshot().unwrap(); | ||
| 824 | let store = Store::parse(&local).unwrap(); | ||
| 825 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 826 | let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; | ||
| 827 | drop(cache); | ||
| 828 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 829 | let current_version: u32 = db | ||
| 830 | .pragma_query_value(None, "user_version", |row| row.get(0)) | ||
| 831 | .unwrap(); | ||
| 832 | let asset = "00112233-4455-6677-8899-aabbccddeeff.onebin"; | ||
| 833 | let data = b"retained media"; | ||
| 834 | db.execute( | ||
| 835 | "INSERT INTO assets VALUES (?1,?2,?3)", | ||
| 836 | rusqlite::params![asset, data.as_slice(), Sha256::digest(data).as_slice()], | ||
| 837 | ) | ||
| 838 | .unwrap(); | ||
| 839 | db.execute_batch(&format!( | ||
| 840 | "ALTER TABLE attempt RENAME COLUMN revisions TO revision; | ||
| 841 | DROP TABLE conflicts; CREATE TABLE conflicts ( | ||
| 842 | edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, | ||
| 843 | kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND {ceiling})) STRICT; | ||
| 844 | PRAGMA user_version={version}; | ||
| 845 | UPDATE sqlite_sequence SET seq=1000 WHERE name='edits';" | ||
| 846 | )) | ||
| 847 | .unwrap(); | ||
| 848 | db.execute( | ||
| 849 | "INSERT INTO conflicts VALUES (?1,3)", | ||
| 850 | [i64::try_from(second).unwrap()], | ||
| 851 | ) | ||
| 852 | .unwrap(); | ||
| 853 | db.execute( | ||
| 854 | "INSERT INTO attempt VALUES (1,?1,?2)", | ||
| 855 | rusqlite::params![i64::try_from(first).unwrap(), revision.to_string()], | ||
| 856 | ) | ||
| 857 | .unwrap(); | ||
| 858 | db.execute( | ||
| 859 | "INSERT INTO receipts VALUES (999,?1)", | ||
| 860 | [revision.to_string()], | ||
| 861 | ) | ||
| 862 | .unwrap(); | ||
| 863 | drop(db); | ||
| 864 | let archive = directory.path().join("legacy-recovery.sqlite"); | ||
| 865 | std::fs::copy(&path, &archive).unwrap(); | ||
| 866 | let archive_db = rusqlite::Connection::open(&archive).unwrap(); | ||
| 867 | archive_db | ||
| 868 | .pragma_update(None, "application_id", 0x4f4e4552) | ||
| 869 | .unwrap(); | ||
| 870 | drop(archive_db); | ||
| 871 | let original_archive = std::fs::read(&archive).unwrap(); | ||
| 872 | let recovery = Recovery::open(&archive).unwrap(); | ||
| 873 | assert_eq!( | ||
| 874 | recovery.status(first).unwrap(), | ||
| 875 | Some(EditStatus::AwaitingConfirmation { revision }) | ||
| 876 | ); | ||
| 877 | assert_eq!(recovery.pending().unwrap(), queue); | ||
| 878 | drop(recovery); | ||
| 879 | assert!(std::fs::read(&archive).unwrap() == original_archive); | ||
| 880 | let cache = Replica::open(&path).unwrap(); | ||
| 881 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 882 | assert_eq!( | ||
| 883 | cache.cached_asset(asset, 1024).unwrap(), | ||
| 884 | Some(data.to_vec()) | ||
| 885 | ); | ||
| 886 | assert!(cache.snapshot().unwrap() == local); | ||
| 887 | assert!(cache.remote_snapshot().unwrap() == source); | ||
| 888 | assert_eq!( | ||
| 889 | cache.status(first).unwrap(), | ||
| 890 | Some(EditStatus::AwaitingConfirmation { revision }) | ||
| 891 | ); | ||
| 892 | assert_eq!( | ||
| 893 | cache.status(second).unwrap(), | ||
| 894 | Some(EditStatus::Conflict(ConflictKind::FormattingChanged)) | ||
| 895 | ); | ||
| 896 | assert_eq!( | ||
| 897 | cache.status(999).unwrap(), | ||
| 898 | Some(EditStatus::Published { revision }) | ||
| 899 | ); | ||
| 900 | let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap(); | ||
| 901 | assert_eq!(cache.split(&local, sid, &split).unwrap(), Some(1001)); | ||
| 902 | let pending = cache.pending().unwrap(); | ||
| 903 | let archive = directory.path().join("recovery.sqlite"); | ||
| 904 | cache.export_recovery(&archive).unwrap(); | ||
| 905 | let recovery = Recovery::open(&archive).unwrap(); | ||
| 906 | assert_eq!(recovery.pending().unwrap(), pending); | ||
| 907 | assert_eq!( | ||
| 908 | recovery.cached_asset(asset, 1024).unwrap(), | ||
| 909 | Some(data.to_vec()) | ||
| 910 | ); | ||
| 911 | assert_eq!( | ||
| 912 | recovery.status(first).unwrap(), | ||
| 913 | cache.status(first).unwrap() | ||
| 914 | ); | ||
| 915 | assert_eq!(recovery.receipts().unwrap().get(&999), Some(&revision)); | ||
| 916 | drop(cache); | ||
| 917 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 918 | assert_eq!( | ||
| 919 | db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) | ||
| 920 | .unwrap(), | ||
| 921 | current_version | ||
| 922 | ); | ||
| 923 | db.execute("INSERT INTO conflicts VALUES (1001,6)", []) | ||
| 924 | .unwrap(); | ||
| 925 | drop(db); | ||
| 926 | let cache = Replica::open(&path).unwrap(); | ||
| 927 | assert_eq!( | ||
| 928 | cache.status(1001).unwrap(), | ||
| 929 | Some(EditStatus::Conflict(ConflictKind::ContentChanged)) | ||
| 930 | ); | ||
| 931 | } | ||
| 932 | } | ||
crates/onestore-offline/tests/support/page_schedule.rs deleted-254| ... | @@ -1,254 +0,0 @@ | ||
| 1 | use crate::disk; | ||
| 2 | use onestore::{ | ||
| 3 | CommitError, ExGuid, Insertion, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, | ||
| 4 | document::{Document, Kind}, | ||
| 5 | }; | ||
| 6 | use onestore_offline::{EditStatus, Operation, Remote, Replica}; | ||
| 7 | use std::{io, sync::LazyLock}; | ||
| 8 | |||
| 9 | #[path = "../../../onestore/tests/support/current.rs"] | ||
| 10 | mod current; | ||
| 11 | |||
| 12 | static SOURCE: LazyLock<Vec<u8>> = LazyLock::new(|| { | ||
| 13 | let mut source = onestore::create_section("page-schedule.one", "Original", "Author").unwrap(); | ||
| 14 | for _ in 0..3 { | ||
| 15 | let page = onestore::PageCreation::new(None, Some("Same title"), "Author").unwrap(); | ||
| 16 | source = PreparedEdit::create_page(&source, &page) | ||
| 17 | .unwrap() | ||
| 18 | .as_bytes() | ||
| 19 | .to_vec(); | ||
| 20 | } | ||
| 21 | source | ||
| 22 | }); | ||
| 23 | |||
| 24 | fn pages(source: &[u8]) -> Vec<(ExGuid, ExGuid, u32)> { | ||
| 25 | let store = Store::parse(source).unwrap(); | ||
| 26 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 27 | index.validate_current().unwrap(); | ||
| 28 | let document = Document::parse(&index).unwrap(); | ||
| 29 | document | ||
| 30 | .pages() | ||
| 31 | .unwrap() | ||
| 32 | .into_iter() | ||
| 33 | .map(|(sid, page)| { | ||
| 34 | let space = &document.spaces[&sid]; | ||
| 35 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 36 | let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else { | ||
| 37 | panic!() | ||
| 38 | }; | ||
| 39 | (sid, page, level.unwrap_or(1)) | ||
| 40 | }) | ||
| 41 | .collect() | ||
| 42 | } | ||
| 43 | |||
| 44 | struct Session<'a> { | ||
| 45 | disk: &'a mut disk::Disk, | ||
| 46 | operation: Option<&'a Operation>, | ||
| 47 | } | ||
| 48 | |||
| 49 | impl Remote for Session<'_> { | ||
| 50 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 51 | Ok(self.disk.visible.clone()) | ||
| 52 | } | ||
| 53 | |||
| 54 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 55 | let mut expected = pages(&self.disk.visible); | ||
| 56 | match self.operation.unwrap() { | ||
| 57 | Operation::CreatePage(page) => expected.push((page.space(), page.object(), 1)), | ||
| 58 | Operation::Pages(batch) => { | ||
| 59 | for change in &batch.edits { | ||
| 60 | let at = expected.iter().position(|p| p.0 == change.space()).unwrap(); | ||
| 61 | expected[at].2 = change.level(); | ||
| 62 | if let PagePosition::Before(before) = change.position() { | ||
| 63 | let page = expected.remove(at); | ||
| 64 | let at = before.map_or(expected.len(), |before| { | ||
| 65 | expected.iter().position(|p| p.0 == before).unwrap() | ||
| 66 | }); | ||
| 67 | expected.insert(at, page); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | } | ||
| 71 | Operation::Insert(insertion) => { | ||
| 72 | let store = Store::parse(edit.as_bytes()).unwrap(); | ||
| 73 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 74 | let document = Document::parse(&index).unwrap(); | ||
| 75 | let matching: Vec<_> = document | ||
| 76 | .spaces | ||
| 77 | .values() | ||
| 78 | .filter_map(|space| { | ||
| 79 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 80 | view.nodes.get(&insertion.text_object()) | ||
| 81 | }) | ||
| 82 | .collect(); | ||
| 83 | assert_eq!(matching.len(), 1); | ||
| 84 | assert!( | ||
| 85 | matches!(&matching[0].kind, Kind::RichText { text, .. } if text == "Body 🦋 é") | ||
| 86 | ); | ||
| 87 | } | ||
| 88 | _ => panic!(), | ||
| 89 | } | ||
| 90 | assert_eq!(pages(edit.as_bytes()), expected); | ||
| 91 | let old = current::current(&self.disk.durable); | ||
| 92 | let new = current::current(edit.as_bytes()); | ||
| 93 | let result = edit.commit(self.disk); | ||
| 94 | let observed = current::current(&self.disk.durable); | ||
| 95 | assert!(observed == old || observed == new); | ||
| 96 | if result.is_ok() { | ||
| 97 | assert_eq!(observed, new); | ||
| 98 | } | ||
| 99 | result | ||
| 100 | } | ||
| 101 | |||
| 102 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 103 | onestore::confirm_snapshot(self.disk, snapshot) | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | pub fn run(input: &[u8]) { | ||
| 108 | let directory = tempfile::tempdir().unwrap(); | ||
| 109 | let mut replicas: [Option<Replica>; 12] = std::array::from_fn(|_| None); | ||
| 110 | let mut owned: [Vec<(ExGuid, ExGuid)>; 12] = std::array::from_fn(|_| Vec::new()); | ||
| 111 | let mut disk = disk::Disk { | ||
| 112 | visible: SOURCE.clone(), | ||
| 113 | durable: SOURCE.clone(), | ||
| 114 | operation: 0, | ||
| 115 | fail_at: None, | ||
| 116 | write_limit: 4096, | ||
| 117 | random: 1, | ||
| 118 | }; | ||
| 119 | for step in input.chunks_exact(8).take(48) { | ||
| 120 | let actor = usize::from(step[0]) % replicas.len(); | ||
| 121 | let path = directory.path().join(format!("{actor}.sqlite")); | ||
| 122 | let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, &SOURCE).unwrap()); | ||
| 123 | let snapshot = cache.snapshot().unwrap(); | ||
| 124 | let pending = cache.pending().unwrap(); | ||
| 125 | match step[1] % 8 { | ||
| 126 | 0 | 1 => { | ||
| 127 | let title = (step[2] & 1 != 0).then_some("Same 🦋 é"); | ||
| 128 | let page = onestore::PageCreation::new(None, title, "Author").unwrap(); | ||
| 129 | cache.create_page(&snapshot, &page).unwrap().unwrap(); | ||
| 130 | owned[actor].push((page.space(), page.object())); | ||
| 131 | if step[1] % 8 == 1 { | ||
| 132 | let insertion = | ||
| 133 | Insertion::outline(page.object(), 36.0, 36.0, "Body 🦋 é", "Author") | ||
| 134 | .unwrap(); | ||
| 135 | cache | ||
| 136 | .insert(&cache.snapshot().unwrap(), page.space(), &insertion) | ||
| 137 | .unwrap() | ||
| 138 | .unwrap(); | ||
| 139 | } | ||
| 140 | } | ||
| 141 | 2 => { | ||
| 142 | let statuses: Vec<_> = pending | ||
| 143 | .iter() | ||
| 144 | .map(|edit| cache.status(edit.id).unwrap()) | ||
| 145 | .collect(); | ||
| 146 | replicas[actor] = None; | ||
| 147 | let cache = Replica::open(&path).unwrap(); | ||
| 148 | assert!(cache.snapshot().unwrap() == snapshot); | ||
| 149 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 150 | assert_eq!( | ||
| 151 | pending | ||
| 152 | .iter() | ||
| 153 | .map(|edit| cache.status(edit.id).unwrap()) | ||
| 154 | .collect::<Vec<_>>(), | ||
| 155 | statuses | ||
| 156 | ); | ||
| 157 | replicas[actor] = Some(cache); | ||
| 158 | } | ||
| 159 | 3 | 4 => { | ||
| 160 | disk.operation = 0; | ||
| 161 | disk.write_limit = if step[3] & 1 == 0 { 17 } else { 4096 }; | ||
| 162 | disk.fail_at = | ||
| 163 | (step[2] != 0).then_some(usize::from(u16::from_le_bytes([step[2], step[3]]))); | ||
| 164 | disk.random = u64::from(step[4]) + 1; | ||
| 165 | let prior = pending | ||
| 166 | .first() | ||
| 167 | .and_then(|edit| cache.status(edit.id).unwrap()); | ||
| 168 | let mut session = Session { | ||
| 169 | disk: &mut disk, | ||
| 170 | operation: pending.first().map(|edit| &edit.operation), | ||
| 171 | }; | ||
| 172 | let result = cache.sync_once(&mut session); | ||
| 173 | if matches!(prior, Some(EditStatus::AwaitingConfirmation { .. })) { | ||
| 174 | assert!( | ||
| 175 | result.is_err() | ||
| 176 | || !matches!(result.unwrap(), Some((_, EditStatus::Conflict(_)))) | ||
| 177 | ); | ||
| 178 | } | ||
| 179 | } | ||
| 180 | 5 => { | ||
| 181 | disk.visible.clone_from(&disk.durable); | ||
| 182 | disk.fail_at = None; | ||
| 183 | } | ||
| 184 | 6 => { | ||
| 185 | let order = pages(&snapshot); | ||
| 186 | let count = 1 + usize::from(step[3] & 1 != 0); | ||
| 187 | let selected: Vec<_> = (0..count) | ||
| 188 | .map(|i| order[(usize::from(step[2]) + i) % order.len()].0) | ||
| 189 | .collect(); | ||
| 190 | let anchor = (step[4] & 1 != 0) | ||
| 191 | .then_some(order[usize::from(step[5]) % order.len()].0) | ||
| 192 | .filter(|sid| !selected.contains(sid)); | ||
| 193 | let edits: Vec<_> = selected | ||
| 194 | .iter() | ||
| 195 | .enumerate() | ||
| 196 | .map(|(i, sid)| { | ||
| 197 | let level = u32::from(step[6 + i]) % 3 + 1; | ||
| 198 | if step[4] & 2 == 0 { | ||
| 199 | PageEdit::set_level(*sid, level).unwrap() | ||
| 200 | } else { | ||
| 201 | PageEdit::move_to(*sid, anchor, level).unwrap() | ||
| 202 | } | ||
| 203 | }) | ||
| 204 | .collect(); | ||
| 205 | let expected = PreparedEdit::pages(&snapshot, &edits); | ||
| 206 | match cache.pages(&snapshot, &edits) { | ||
| 207 | Ok(id) => { | ||
| 208 | let expected = expected.unwrap(); | ||
| 209 | assert_eq!( | ||
| 210 | current::current(&cache.snapshot().unwrap()), | ||
| 211 | current::current(expected.as_bytes()) | ||
| 212 | ); | ||
| 213 | assert_eq!(id.is_some(), expected.as_bytes() != snapshot); | ||
| 214 | } | ||
| 215 | Err(_) => { | ||
| 216 | assert!(expected.is_err()); | ||
| 217 | assert_eq!(cache.snapshot().unwrap(), snapshot); | ||
| 218 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 219 | } | ||
| 220 | } | ||
| 221 | } | ||
| 222 | 7 => { | ||
| 223 | if let Some(edit) = pending.first() | ||
| 224 | && matches!( | ||
| 225 | cache.status(edit.id).unwrap(), | ||
| 226 | Some(EditStatus::Conflict(_)) | ||
| 227 | ) | ||
| 228 | && let Operation::Pages(batch) = &edit.operation | ||
| 229 | { | ||
| 230 | let remote = cache.remote_snapshot().unwrap(); | ||
| 231 | let order = pages(&remote); | ||
| 232 | let anchor = (step[2] & 1 != 0) | ||
| 233 | .then_some(order[usize::from(step[3]) % order.len()].0) | ||
| 234 | .filter(|sid| batch.edits.iter().all(|e| e.space() != *sid)); | ||
| 235 | let edits: Vec<_> = batch | ||
| 236 | .edits | ||
| 237 | .iter() | ||
| 238 | .map(|edit| edit.reposition(PagePosition::Before(anchor), 1).unwrap()) | ||
| 239 | .collect(); | ||
| 240 | let result = cache.rebase_pages_conflict(edit.id, &snapshot, &remote, &edits); | ||
| 241 | assert_eq!(cache.snapshot().unwrap(), snapshot); | ||
| 242 | if result.is_err() { | ||
| 243 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 244 | } | ||
| 245 | } | ||
| 246 | } | ||
| 247 | _ => unreachable!(), | ||
| 248 | } | ||
| 249 | let local = pages(&replicas[actor].as_ref().unwrap().snapshot().unwrap()); | ||
| 250 | for page in &owned[actor] { | ||
| 251 | assert!(local.iter().any(|(sid, oid, _)| (*sid, *oid) == *page)); | ||
| 252 | } | ||
| 253 | } | ||
| 254 | } | ||
crates/onestore-offline/tests/support/tree_schedule.rs deleted-271| ... | @@ -1,271 +0,0 @@ | ||
| 1 | use crate::disk; | ||
| 2 | use onestore::{ | ||
| 3 | CommitError, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, TreeEdit, | ||
| 4 | document::{Document, Kind}, | ||
| 5 | }; | ||
| 6 | use onestore_offline::{EditStatus, Operation, Remote, Replica}; | ||
| 7 | use std::{io, sync::LazyLock}; | ||
| 8 | |||
| 9 | static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid)> = LazyLock::new(|| { | ||
| 10 | let mut source = | ||
| 11 | onestore::create_section("tree-schedule.one", "Original 🦀 é 0", "Author").unwrap(); | ||
| 12 | let store = Store::parse(&source).unwrap(); | ||
| 13 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 14 | let document = Document::parse(&index).unwrap(); | ||
| 15 | let (sid, page) = document.pages().unwrap()[0]; | ||
| 16 | let space = &document.spaces[&sid]; | ||
| 17 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 18 | let outline = *view.nodes[&page] | ||
| 19 | .children | ||
| 20 | .iter() | ||
| 21 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 22 | .unwrap(); | ||
| 23 | for at in 1..12 { | ||
| 24 | let insert = | ||
| 25 | Insertion::paragraph(outline, None, &format!("Original 🦀 é {at}"), "Author").unwrap(); | ||
| 26 | source = PreparedEdit::insert(&source, sid, &insert) | ||
| 27 | .unwrap() | ||
| 28 | .as_bytes() | ||
| 29 | .to_vec(); | ||
| 30 | } | ||
| 31 | (source, sid, outline) | ||
| 32 | }); | ||
| 33 | |||
| 34 | fn rows(source: &[u8]) -> Vec<(ExGuid, ExGuid, String)> { | ||
| 35 | let (_, sid, outline) = &*SOURCE; | ||
| 36 | let store = Store::parse(source).unwrap(); | ||
| 37 | assert!(store.checksum_mismatches.is_empty()); | ||
| 38 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 39 | index.validate_current().unwrap(); | ||
| 40 | let document = Document::parse(&index).unwrap(); | ||
| 41 | let space = &document.spaces[sid]; | ||
| 42 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 43 | let (_, page) = document | ||
| 44 | .pages() | ||
| 45 | .unwrap() | ||
| 46 | .into_iter() | ||
| 47 | .find(|(space, _)| space == sid) | ||
| 48 | .unwrap(); | ||
| 49 | if !view.nodes[&page].children.contains(outline) { | ||
| 50 | return Vec::new(); | ||
| 51 | } | ||
| 52 | let node = &view.nodes[outline]; | ||
| 53 | let mut rows = Vec::new(); | ||
| 54 | for paragraph in &node.children { | ||
| 55 | let node = &view.nodes[paragraph]; | ||
| 56 | assert!(node.children.is_empty()); | ||
| 57 | let [text] = node.content.as_slice() else { | ||
| 58 | panic!() | ||
| 59 | }; | ||
| 60 | let Kind::RichText { text: value, .. } = &view.nodes[text].kind else { | ||
| 61 | panic!() | ||
| 62 | }; | ||
| 63 | rows.push((*paragraph, *text, value.clone())); | ||
| 64 | } | ||
| 65 | rows | ||
| 66 | } | ||
| 67 | |||
| 68 | struct Session<'a> { | ||
| 69 | disk: &'a mut disk::Disk, | ||
| 70 | operation: Option<&'a Operation>, | ||
| 71 | } | ||
| 72 | |||
| 73 | impl Remote for Session<'_> { | ||
| 74 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 75 | Ok(self.disk.visible.clone()) | ||
| 76 | } | ||
| 77 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 78 | let before = rows(&self.disk.visible); | ||
| 79 | let mut expected = before.clone(); | ||
| 80 | match self.operation.unwrap() { | ||
| 81 | Operation::Tree(edit) => { | ||
| 82 | let at = expected | ||
| 83 | .iter() | ||
| 84 | .position(|(id, _, _)| *id == edit.intent.object()) | ||
| 85 | .unwrap(); | ||
| 86 | let row = expected.remove(at); | ||
| 87 | if let Some((parent, anchor)) = edit.intent.destination() { | ||
| 88 | assert_eq!(parent, SOURCE.2); | ||
| 89 | let at = anchor | ||
| 90 | .map(|anchor| { | ||
| 91 | expected | ||
| 92 | .iter() | ||
| 93 | .position(|(id, _, _)| *id == anchor) | ||
| 94 | .unwrap() | ||
| 95 | }) | ||
| 96 | .unwrap_or(expected.len()); | ||
| 97 | expected.insert(at, row); | ||
| 98 | } | ||
| 99 | } | ||
| 100 | Operation::Text(edit) => { | ||
| 101 | assert_eq!(edit.range, 0..1); | ||
| 102 | let (_, _, text) = expected | ||
| 103 | .iter_mut() | ||
| 104 | .find(|(_, id, _)| *id == edit.object) | ||
| 105 | .unwrap(); | ||
| 106 | text.replace_range(0..1, &edit.replacement); | ||
| 107 | } | ||
| 108 | Operation::Format(format) => { | ||
| 109 | let [onestore::TextAttribute::FontSize(size)] = format.attributes.as_slice() else { | ||
| 110 | panic!() | ||
| 111 | }; | ||
| 112 | let store = Store::parse(edit.as_bytes()).unwrap(); | ||
| 113 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 114 | let document = Document::parse(&index).unwrap(); | ||
| 115 | let space = &document.spaces[&SOURCE.1]; | ||
| 116 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 117 | assert_eq!( | ||
| 118 | view.text_runs(format.object).unwrap()[0].format.font_size, | ||
| 119 | Some(*size) | ||
| 120 | ); | ||
| 121 | } | ||
| 122 | _ => panic!(), | ||
| 123 | } | ||
| 124 | assert_eq!(rows(edit.as_bytes()), expected); | ||
| 125 | let result = edit.commit(self.disk); | ||
| 126 | let durable = rows(&self.disk.durable); | ||
| 127 | assert!(durable == before || durable == expected); | ||
| 128 | if result.is_ok() { | ||
| 129 | assert_eq!(durable, expected); | ||
| 130 | } | ||
| 131 | result | ||
| 132 | } | ||
| 133 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 134 | onestore::confirm_snapshot(self.disk, snapshot) | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | pub fn run(input: &[u8]) { | ||
| 139 | let (source, sid, outline) = &*SOURCE; | ||
| 140 | let directory = tempfile::tempdir().unwrap(); | ||
| 141 | let mut replicas: [Option<Replica>; 12] = std::array::from_fn(|_| None); | ||
| 142 | let mut disk = disk::Disk { | ||
| 143 | visible: source.clone(), | ||
| 144 | durable: source.clone(), | ||
| 145 | operation: 0, | ||
| 146 | fail_at: None, | ||
| 147 | write_limit: 4096, | ||
| 148 | random: 1, | ||
| 149 | }; | ||
| 150 | for step in input.chunks_exact(8).take(48) { | ||
| 151 | let actor = usize::from(step[0]) % replicas.len(); | ||
| 152 | let path = directory.path().join(format!("{actor}.sqlite")); | ||
| 153 | let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap()); | ||
| 154 | let snapshot = cache.snapshot().unwrap(); | ||
| 155 | let pending = cache.pending().unwrap(); | ||
| 156 | let local = rows(&snapshot); | ||
| 157 | match step[1] % 8 { | ||
| 158 | 0..=2 if !local.is_empty() => { | ||
| 159 | let target = usize::from(step[2]) % local.len(); | ||
| 160 | let id = match step[1] % 8 { | ||
| 161 | 0 => { | ||
| 162 | let before = | ||
| 163 | (step[3] & 1 == 0).then(|| local[usize::from(step[4]) % local.len()].0); | ||
| 164 | cache | ||
| 165 | .tree( | ||
| 166 | &snapshot, | ||
| 167 | *sid, | ||
| 168 | &TreeEdit::move_to(local[target].0, *outline, before, "Offline") | ||
| 169 | .unwrap(), | ||
| 170 | ) | ||
| 171 | .unwrap() | ||
| 172 | } | ||
| 173 | 1 => cache | ||
| 174 | .tree( | ||
| 175 | &snapshot, | ||
| 176 | *sid, | ||
| 177 | &TreeEdit::delete(local[target].0, "Offline").unwrap(), | ||
| 178 | ) | ||
| 179 | .unwrap(), | ||
| 180 | _ if step[3] & 1 == 0 => cache | ||
| 181 | .edit_text( | ||
| 182 | &snapshot, | ||
| 183 | *sid, | ||
| 184 | local[target].1, | ||
| 185 | 0..1, | ||
| 186 | &char::from(b'A' + step[3] % 26).to_string(), | ||
| 187 | ) | ||
| 188 | .unwrap(), | ||
| 189 | _ => cache | ||
| 190 | .format( | ||
| 191 | &snapshot, | ||
| 192 | *sid, | ||
| 193 | local[target].1, | ||
| 194 | 0..1, | ||
| 195 | &[onestore::TextAttribute::FontSize( | ||
| 196 | 12.0 + f32::from(step[3] % 20), | ||
| 197 | )], | ||
| 198 | ) | ||
| 199 | .unwrap(), | ||
| 200 | }; | ||
| 201 | let next = cache.pending().unwrap(); | ||
| 202 | assert_eq!(next[..pending.len()], pending); | ||
| 203 | assert_eq!(next.len(), pending.len() + usize::from(id.is_some())); | ||
| 204 | } | ||
| 205 | 3 | 4 => { | ||
| 206 | disk.operation = 0; | ||
| 207 | disk.fail_at = (step[4] != 0) | ||
| 208 | .then_some(usize::from(u16::from_le_bytes([step[4], step[5]])) % 2048 + 1); | ||
| 209 | disk.write_limit = if step[6] & 1 == 0 { 17 } else { 4096 }; | ||
| 210 | disk.random = u64::from(step[7]) + 1; | ||
| 211 | let _ = cache.sync_once(&mut Session { | ||
| 212 | disk: &mut disk, | ||
| 213 | operation: pending.first().map(|p| &p.operation), | ||
| 214 | }); | ||
| 215 | let next = cache.pending().unwrap(); | ||
| 216 | if next.len() == pending.len() { | ||
| 217 | assert_eq!(next, pending); | ||
| 218 | } else { | ||
| 219 | assert_eq!(next, pending[1..]); | ||
| 220 | let Some(EditStatus::Published { revision }) = | ||
| 221 | cache.status(pending[0].id).unwrap() | ||
| 222 | else { | ||
| 223 | panic!() | ||
| 224 | }; | ||
| 225 | let store = Store::parse(&disk.durable).unwrap(); | ||
| 226 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 227 | assert!(index.spaces[sid].revisions.contains_key(&revision)); | ||
| 228 | } | ||
| 229 | if !next.is_empty() { | ||
| 230 | assert!(cache.snapshot().unwrap() == snapshot); | ||
| 231 | } | ||
| 232 | disk.visible.clone_from(&disk.durable); | ||
| 233 | } | ||
| 234 | 5 => { | ||
| 235 | let mut expected = rows(&disk.visible); | ||
| 236 | if !expected.is_empty() { | ||
| 237 | let target = usize::from(step[2]) % expected.len(); | ||
| 238 | let (_, text, content) = &mut expected[target]; | ||
| 239 | let at = u32::try_from(content.encode_utf16().count()).unwrap(); | ||
| 240 | content.push('R'); | ||
| 241 | let snapshot = disk.visible.clone(); | ||
| 242 | let edit = PreparedEdit::text(&snapshot, *sid, *text, at..at, "R").unwrap(); | ||
| 243 | disk.fail_at = None; | ||
| 244 | edit.commit(&mut disk).unwrap(); | ||
| 245 | assert_eq!(rows(&disk.durable), expected); | ||
| 246 | } | ||
| 247 | } | ||
| 248 | _ => { | ||
| 249 | if step[1] % 8 == 6 | ||
| 250 | && let Some(first) = pending.first() | ||
| 251 | && matches!(first.operation, Operation::Tree(_)) | ||
| 252 | && matches!( | ||
| 253 | cache.status(first.id).unwrap(), | ||
| 254 | Some(EditStatus::Conflict(_)) | ||
| 255 | ) | ||
| 256 | { | ||
| 257 | let remote = cache.remote_snapshot().unwrap(); | ||
| 258 | let _ = cache.rebase_tree_conflict(first.id, &snapshot, &remote); | ||
| 259 | } | ||
| 260 | let pending = cache.pending().unwrap(); | ||
| 261 | drop(replicas[actor].take()); | ||
| 262 | let cache = Replica::open(&path).unwrap(); | ||
| 263 | assert!(cache.snapshot().unwrap() == snapshot); | ||
| 264 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 265 | replicas[actor] = Some(cache); | ||
| 266 | } | ||
| 267 | } | ||
| 268 | rows(&disk.durable); | ||
| 269 | rows(&replicas[actor].as_ref().unwrap().snapshot().unwrap()); | ||
| 270 | } | ||
| 271 | } | ||
crates/onestore-offline/tests/sync.rs deleted-3932| ... | @@ -1,3932 +0,0 @@ | ||
| 1 | use onestore::{ | ||
| 2 | CommitError, CommitIo, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, | ||
| 3 | document::{Document, Kind}, | ||
| 4 | }; | ||
| 5 | use onestore_offline::{ConflictKind, EditStatus, Error, Remote, Replica}; | ||
| 6 | use std::io; | ||
| 7 | |||
| 8 | #[path = "../../onestore/tests/support/disk.rs"] | ||
| 9 | mod disk; | ||
| 10 | #[path = "sync/outline.rs"] | ||
| 11 | mod outline; | ||
| 12 | #[path = "sync/page.rs"] | ||
| 13 | mod page; | ||
| 14 | #[path = "support/page_schedule.rs"] | ||
| 15 | mod page_schedule; | ||
| 16 | #[path = "sync/pages.rs"] | ||
| 17 | mod pages; | ||
| 18 | #[path = "sync/tree.rs"] | ||
| 19 | mod tree; | ||
| 20 | #[path = "support/tree_schedule.rs"] | ||
| 21 | mod tree_schedule; | ||
| 22 | |||
| 23 | mod paragraph { | ||
| 24 | use super::*; | ||
| 25 | use onestore::{Insertion, ParagraphJoin, ParagraphSplit, TextAttribute}; | ||
| 26 | |||
| 27 | fn fixture() -> (Vec<u8>, ExGuid, ExGuid, ExGuid) { | ||
| 28 | let source = onestore::create_section("paragraph.one", "ab🦀cd", "Fixture").unwrap(); | ||
| 29 | let (sid, left, _) = text(&source); | ||
| 30 | let store = Store::parse(&source).unwrap(); | ||
| 31 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 32 | let doc = Document::parse(&index).unwrap(); | ||
| 33 | let space = &doc.spaces[&sid]; | ||
| 34 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 35 | let parent = *view | ||
| 36 | .nodes | ||
| 37 | .iter() | ||
| 38 | .find(|(_, node)| node.content == [left]) | ||
| 39 | .unwrap() | ||
| 40 | .0; | ||
| 41 | (source, sid, left, parent) | ||
| 42 | } | ||
| 43 | |||
| 44 | fn native_reconciliation(keyboard: bool) -> (Vec<u8>, Vec<serde_json::Value>) { | ||
| 45 | let source = include_bytes!( | ||
| 46 | "../../../corpus/paragraph-edit/reconciliation/before/notebook/synthetic.one" | ||
| 47 | ); | ||
| 48 | let native: &[u8] = if keyboard { | ||
| 49 | include_bytes!( | ||
| 50 | "../../../corpus/paragraph-edit/reconciliation/keyboard/notebook/synthetic.one" | ||
| 51 | ) | ||
| 52 | } else { | ||
| 53 | include_bytes!( | ||
| 54 | "../../../corpus/paragraph-edit/reconciliation/remote/notebook/synthetic.one" | ||
| 55 | ) | ||
| 56 | }; | ||
| 57 | let store = Store::parse(source).unwrap(); | ||
| 58 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 59 | let document = Document::parse(&index).unwrap(); | ||
| 60 | let mut server = Server::new(native); | ||
| 61 | let mut recorded = Vec::new(); | ||
| 62 | let mut counts = [0; 3]; | ||
| 63 | for (sid, page) in document.pages().unwrap() { | ||
| 64 | let space = &document.spaces[&sid]; | ||
| 65 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 66 | let Kind::Metadata { | ||
| 67 | title: Some(name), .. | ||
| 68 | } = &view.nodes[&view.roots[&2]].kind | ||
| 69 | else { | ||
| 70 | continue; | ||
| 71 | }; | ||
| 72 | if !name.starts_with("Split ") && !name.starts_with("Join ") { | ||
| 73 | continue; | ||
| 74 | } | ||
| 75 | let outline = view.nodes[&page] | ||
| 76 | .children | ||
| 77 | .iter() | ||
| 78 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 79 | .unwrap(); | ||
| 80 | let children = &view.nodes[outline].children; | ||
| 81 | let left = view.nodes[&children[0]].content[0]; | ||
| 82 | let split = name.starts_with("Split "); | ||
| 83 | let adoption = name == "Join empty adoption"; | ||
| 84 | let directory = tempfile::tempdir().unwrap(); | ||
| 85 | let path = directory.path().join("native-reconciliation.sqlite"); | ||
| 86 | let mut cache = Replica::create(&path, source).unwrap(); | ||
| 87 | let (id, target, intent) = if split { | ||
| 88 | let intent = ParagraphSplit::new(left, 2, "Offline author").unwrap(); | ||
| 89 | ( | ||
| 90 | cache.split(source, sid, &intent).unwrap().unwrap(), | ||
| 91 | intent.text_object(), | ||
| 92 | serde_json::to_value(intent).unwrap(), | ||
| 93 | ) | ||
| 94 | } else { | ||
| 95 | let right = view.nodes[&children[1]].content[0]; | ||
| 96 | let intent = ParagraphJoin::new(left, right, "Offline author").unwrap(); | ||
| 97 | ( | ||
| 98 | cache.join(source, sid, &intent).unwrap().unwrap(), | ||
| 99 | if adoption { right } else { left }, | ||
| 100 | serde_json::to_value(intent).unwrap(), | ||
| 101 | ) | ||
| 102 | }; | ||
| 103 | drop(cache); | ||
| 104 | cache = Replica::open(&path).unwrap(); | ||
| 105 | let at = if split { | ||
| 106 | 2 | ||
| 107 | } else if adoption { | ||
| 108 | 1 | ||
| 109 | } else { | ||
| 110 | 4 | ||
| 111 | }; | ||
| 112 | let dependent = cache | ||
| 113 | .edit_text( | ||
| 114 | &cache.snapshot().unwrap(), | ||
| 115 | sid, | ||
| 116 | target, | ||
| 117 | at..at + 1, | ||
| 118 | if adoption { "I" } else { "C" }, | ||
| 119 | ) | ||
| 120 | .unwrap() | ||
| 121 | .unwrap(); | ||
| 122 | let local = cache.snapshot().unwrap(); | ||
| 123 | let pending = cache.pending().unwrap(); | ||
| 124 | drop(cache); | ||
| 125 | cache = Replica::open(&path).unwrap(); | ||
| 126 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 127 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 128 | let previous = server.publications; | ||
| 129 | let remote = server.visible.clone(); | ||
| 130 | let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 131 | assert_eq!(actual, id); | ||
| 132 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 133 | if !keyboard { | ||
| 134 | counts[2] += 1; | ||
| 135 | assert_eq!( | ||
| 136 | status, | ||
| 137 | EditStatus::Conflict(ConflictKind::TargetUnavailable), | ||
| 138 | "{name}" | ||
| 139 | ); | ||
| 140 | assert_eq!(server.publications, previous); | ||
| 141 | assert_eq!(server.visible, remote); | ||
| 142 | drop(cache); | ||
| 143 | cache = Replica::open(&path).unwrap(); | ||
| 144 | assert_eq!(cache.status(id).unwrap(), Some(status)); | ||
| 145 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 146 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 147 | continue; | ||
| 148 | } | ||
| 149 | let automatic = ["prefix", "format", "sibling"] | ||
| 150 | .iter() | ||
| 151 | .any(|suffix| name.ends_with(suffix)); | ||
| 152 | if automatic { | ||
| 153 | counts[0] += 1; | ||
| 154 | assert!( | ||
| 155 | matches!(status, EditStatus::Published { .. }), | ||
| 156 | "{name}: {status:?}" | ||
| 157 | ); | ||
| 158 | } else { | ||
| 159 | let expected = if name.ends_with("boundary") || adoption { | ||
| 160 | ConflictKind::TextChanged | ||
| 161 | } else { | ||
| 162 | ConflictKind::StructureChanged | ||
| 163 | }; | ||
| 164 | assert_eq!(status, EditStatus::Conflict(expected), "{name}"); | ||
| 165 | assert_eq!(server.publications, previous); | ||
| 166 | assert_eq!(server.visible, remote); | ||
| 167 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 168 | drop(cache); | ||
| 169 | cache = Replica::open(&path).unwrap(); | ||
| 170 | assert_eq!(cache.status(id).unwrap(), Some(status)); | ||
| 171 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 172 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 173 | if adoption || name == "Join remote list" { | ||
| 174 | counts[2] += 1; | ||
| 175 | assert!(cache.rebase_join_conflict(id, &local, &remote).is_err()); | ||
| 176 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 177 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 178 | assert_eq!(cache.status(id).unwrap(), Some(status)); | ||
| 179 | recorded.push(serde_json::json!({"case": name, "space": sid, "intent": intent, "outcome": "retained_conflict"})); | ||
| 180 | continue; | ||
| 181 | } | ||
| 182 | counts[1] += 1; | ||
| 183 | if split { | ||
| 184 | let offset = if name.ends_with("boundary") { 3 } else { 2 }; | ||
| 185 | cache | ||
| 186 | .rebase_conflict(id, &local, &remote, offset..offset) | ||
| 187 | .unwrap(); | ||
| 188 | let onestore_offline::Operation::Split(edit) = | ||
| 189 | &cache.pending().unwrap()[0].operation | ||
| 190 | else { | ||
| 191 | panic!() | ||
| 192 | }; | ||
| 193 | assert_eq!( | ||
| 194 | edit.intent, | ||
| 195 | serde_json::from_value::<ParagraphSplit>(intent.clone()) | ||
| 196 | .unwrap() | ||
| 197 | .reposition(offset) | ||
| 198 | ); | ||
| 199 | } else { | ||
| 200 | cache.rebase_join_conflict(id, &local, &remote).unwrap(); | ||
| 201 | } | ||
| 202 | drop(cache); | ||
| 203 | cache = Replica::open(&path).unwrap(); | ||
| 204 | let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 205 | assert_eq!(actual, id); | ||
| 206 | assert!( | ||
| 207 | matches!(status, EditStatus::Published { .. }), | ||
| 208 | "{name}: {status:?}" | ||
| 209 | ); | ||
| 210 | } | ||
| 211 | drop(cache); | ||
| 212 | cache = Replica::open(&path).unwrap(); | ||
| 213 | let first_receipt = cache.status(id).unwrap().unwrap(); | ||
| 214 | let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 215 | assert_eq!(actual, dependent); | ||
| 216 | assert!( | ||
| 217 | matches!(status, EditStatus::Published { .. }), | ||
| 218 | "{name} dependent: {status:?}" | ||
| 219 | ); | ||
| 220 | assert_eq!(server.publications, previous + 2); | ||
| 221 | drop(cache); | ||
| 222 | cache = Replica::open(&path).unwrap(); | ||
| 223 | assert_eq!(cache.status(id).unwrap(), Some(first_receipt)); | ||
| 224 | assert_eq!(cache.status(dependent).unwrap(), Some(status)); | ||
| 225 | assert!(cache.pending().unwrap().is_empty()); | ||
| 226 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 227 | let EditStatus::Published { revision } = first_receipt else { | ||
| 228 | panic!() | ||
| 229 | }; | ||
| 230 | let EditStatus::Published { | ||
| 231 | revision: dependent_revision, | ||
| 232 | } = status | ||
| 233 | else { | ||
| 234 | panic!() | ||
| 235 | }; | ||
| 236 | recorded.push(serde_json::json!({"case": name, "space": sid, "intent": intent, | ||
| 237 | "outcome": if automatic { "automatic" } else { "reviewed" }, "receipt": revision, "dependent_receipt": dependent_revision})); | ||
| 238 | } | ||
| 239 | assert_eq!(counts, if keyboard { [6, 8, 2] } else { [0, 0, 16] }); | ||
| 240 | assert_eq!(server.publications, if keyboard { 28 } else { 0 }); | ||
| 241 | (server.durable, recorded) | ||
| 242 | } | ||
| 243 | |||
| 244 | #[test] | ||
| 245 | fn native_com_replacement_preserves_every_local_paragraph_branch() { | ||
| 246 | native_reconciliation(false); | ||
| 247 | } | ||
| 248 | |||
| 249 | #[test] | ||
| 250 | fn native_changes_commute_or_retain_reviewable_paragraph_dependencies() { | ||
| 251 | native_reconciliation(true); | ||
| 252 | } | ||
| 253 | |||
| 254 | #[test] | ||
| 255 | #[ignore = "exports native-controlled reconciliation for cold OneNote validation"] | ||
| 256 | fn export_native_reconciliation() { | ||
| 257 | let output = std::path::PathBuf::from( | ||
| 258 | std::env::var_os("ONESTORE_NATIVE_RECONCILIATION_OUTPUT").unwrap(), | ||
| 259 | ); | ||
| 260 | assert!(output.is_absolute()); | ||
| 261 | std::fs::create_dir(&output).unwrap(); | ||
| 262 | let (bytes, cases) = native_reconciliation(true); | ||
| 263 | std::fs::create_dir(output.join("candidate")).unwrap(); | ||
| 264 | std::fs::write(output.join("candidate/synthetic.one"), bytes).unwrap(); | ||
| 265 | std::fs::write( | ||
| 266 | output.join("manifest.json"), | ||
| 267 | serde_json::to_vec_pretty(&cases).unwrap(), | ||
| 268 | ) | ||
| 269 | .unwrap(); | ||
| 270 | } | ||
| 271 | |||
| 272 | #[test] | ||
| 273 | fn native_splits_retain_independent_local_identities_and_changed_boundaries() { | ||
| 274 | let source = include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"); | ||
| 275 | let native = include_bytes!("../../../corpus/paragraph-edit/split/notebook/synthetic.one"); | ||
| 276 | let manifest: serde_json::Value = | ||
| 277 | serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")) | ||
| 278 | .unwrap(); | ||
| 279 | let store = Store::parse(source).unwrap(); | ||
| 280 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 281 | let document = Document::parse(&index).unwrap(); | ||
| 282 | let mut accepted = 0; | ||
| 283 | let mut conflicts = 0; | ||
| 284 | for case in manifest["cases"].as_array().unwrap() { | ||
| 285 | if case["case"] == "Split before hyperlink" { | ||
| 286 | continue; | ||
| 287 | } | ||
| 288 | let left: ExGuid = serde_json::from_value(case["original_text"].clone()).unwrap(); | ||
| 289 | let native_right: ExGuid = serde_json::from_value(case["new_text"].clone()).unwrap(); | ||
| 290 | let (sid, _) = document | ||
| 291 | .pages() | ||
| 292 | .unwrap() | ||
| 293 | .into_iter() | ||
| 294 | .find(|(sid, _)| { | ||
| 295 | let space = &document.spaces[sid]; | ||
| 296 | space.revisions[&space.contexts[&ExGuid::default()]] | ||
| 297 | .nodes | ||
| 298 | .contains_key(&left) | ||
| 299 | }) | ||
| 300 | .unwrap(); | ||
| 301 | let split = ParagraphSplit::new( | ||
| 302 | left, | ||
| 303 | u32::try_from(case["offset_utf16"].as_u64().unwrap()).unwrap(), | ||
| 304 | "Offline author", | ||
| 305 | ) | ||
| 306 | .unwrap(); | ||
| 307 | let directory = tempfile::tempdir().unwrap(); | ||
| 308 | let path = directory.path().join("native-split.sqlite"); | ||
| 309 | let mut cache = Replica::create(&path, source).unwrap(); | ||
| 310 | let id = cache.split(source, sid, &split).unwrap().unwrap(); | ||
| 311 | let current = cache.snapshot().unwrap(); | ||
| 312 | let dependent = cache | ||
| 313 | .edit_text( | ||
| 314 | &current, | ||
| 315 | sid, | ||
| 316 | split.text_object(), | ||
| 317 | 0..0, | ||
| 318 | "Local dependent edit: ", | ||
| 319 | ) | ||
| 320 | .unwrap() | ||
| 321 | .unwrap(); | ||
| 322 | let local = cache.snapshot().unwrap(); | ||
| 323 | let pending = cache.pending().unwrap(); | ||
| 324 | let mut server = Server::new(native); | ||
| 325 | let (published, status) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 326 | assert_eq!(published, id); | ||
| 327 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 328 | if matches!(case["case"].as_str().unwrap(), "Split end" | "Split empty") { | ||
| 329 | accepted += 1; | ||
| 330 | assert!( | ||
| 331 | matches!(status, EditStatus::Published { .. }), | ||
| 332 | "{}: {status:?}", | ||
| 333 | case["case"] | ||
| 334 | ); | ||
| 335 | drop(cache); | ||
| 336 | cache = Replica::open(&path).unwrap(); | ||
| 337 | assert_eq!(cache.sync_once(&mut server).unwrap().unwrap().0, dependent); | ||
| 338 | assert_eq!(server.publications, 2); | ||
| 339 | let store = Store::parse(&server.visible).unwrap(); | ||
| 340 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 341 | let document = Document::parse(&index).unwrap(); | ||
| 342 | let space = &document.spaces[&sid]; | ||
| 343 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 344 | let original: ExGuid = | ||
| 345 | serde_json::from_value(case["original_paragraph"].clone()).unwrap(); | ||
| 346 | let native_paragraph: ExGuid = | ||
| 347 | serde_json::from_value(case["new_paragraph"].clone()).unwrap(); | ||
| 348 | let parent = view | ||
| 349 | .nodes | ||
| 350 | .values() | ||
| 351 | .find(|node| node.children.contains(&original)) | ||
| 352 | .unwrap(); | ||
| 353 | let position = parent | ||
| 354 | .children | ||
| 355 | .iter() | ||
| 356 | .position(|id| *id == original) | ||
| 357 | .unwrap(); | ||
| 358 | assert_eq!( | ||
| 359 | &parent.children[position..position + 3], | ||
| 360 | &[original, split.object(), native_paragraph] | ||
| 361 | ); | ||
| 362 | assert!( | ||
| 363 | matches!(&view.nodes[&native_right].kind, Kind::RichText {text,..} if text.is_empty()) | ||
| 364 | ); | ||
| 365 | assert!( | ||
| 366 | matches!(&view.nodes[&split.text_object()].kind, Kind::RichText {text,..} if text == "Local dependent edit: ") | ||
| 367 | ); | ||
| 368 | } else { | ||
| 369 | conflicts += 1; | ||
| 370 | assert!( | ||
| 371 | matches!( | ||
| 372 | status, | ||
| 373 | EditStatus::Conflict( | ||
| 374 | ConflictKind::TextChanged | ConflictKind::StructureChanged | ||
| 375 | ) | ||
| 376 | ), | ||
| 377 | "{}: {status:?}", | ||
| 378 | case["case"] | ||
| 379 | ); | ||
| 380 | assert_eq!(server.publications, 0); | ||
| 381 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 382 | drop(cache); | ||
| 383 | cache = Replica::open(&path).unwrap(); | ||
| 384 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 385 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 386 | assert_eq!(cache.status(id).unwrap(), Some(status)); | ||
| 387 | } | ||
| 388 | } | ||
| 389 | assert_eq!((accepted, conflicts), (2, 10)); | ||
| 390 | } | ||
| 391 | |||
| 392 | #[test] | ||
| 393 | fn native_joins_do_not_acknowledge_or_discard_an_independent_local_branch() { | ||
| 394 | let source = include_bytes!( | ||
| 395 | "../../../corpus/paragraph-edit/join-tags/before/notebook/synthetic.one" | ||
| 396 | ); | ||
| 397 | let native = include_bytes!( | ||
| 398 | "../../../corpus/paragraph-edit/join-tags/joined/notebook/synthetic.one" | ||
| 399 | ); | ||
| 400 | let store = Store::parse(source).unwrap(); | ||
| 401 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 402 | let document = Document::parse(&index).unwrap(); | ||
| 403 | let mut cases = 0; | ||
| 404 | for (sid, page) in document.pages().unwrap() { | ||
| 405 | let space = &document.spaces[&sid]; | ||
| 406 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 407 | let Kind::Metadata { | ||
| 408 | title: Some(name), .. | ||
| 409 | } = &view.nodes[&view.roots[&2]].kind | ||
| 410 | else { | ||
| 411 | continue; | ||
| 412 | }; | ||
| 413 | if !name.starts_with("Join ") { | ||
| 414 | continue; | ||
| 415 | } | ||
| 416 | cases += 1; | ||
| 417 | let outline = view.nodes[&page] | ||
| 418 | .children | ||
| 419 | .iter() | ||
| 420 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 421 | .unwrap(); | ||
| 422 | let children = &view.nodes[outline].children; | ||
| 423 | let left = view.nodes[&children[0]].content[0]; | ||
| 424 | let right = view.nodes[&children[1]].content[0]; | ||
| 425 | let Kind::RichText { | ||
| 426 | text: left_text, .. | ||
| 427 | } = &view.nodes[&left].kind | ||
| 428 | else { | ||
| 429 | panic!() | ||
| 430 | }; | ||
| 431 | let survivor = if left_text.is_empty() { right } else { left }; | ||
| 432 | let directory = tempfile::tempdir().unwrap(); | ||
| 433 | let path = directory.path().join("native-join.sqlite"); | ||
| 434 | let mut cache = Replica::create(&path, source).unwrap(); | ||
| 435 | let join = ParagraphJoin::new(left, right, "Offline author").unwrap(); | ||
| 436 | let id = cache.join(source, sid, &join).unwrap().unwrap(); | ||
| 437 | let current = cache.snapshot().unwrap(); | ||
| 438 | cache | ||
| 439 | .edit_text(&current, sid, survivor, 0..0, "Local dependent edit: ") | ||
| 440 | .unwrap() | ||
| 441 | .unwrap(); | ||
| 442 | let local = cache.snapshot().unwrap(); | ||
| 443 | let pending = cache.pending().unwrap(); | ||
| 444 | let mut server = Server::new(native); | ||
| 445 | let (conflicted, status) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 446 | assert_eq!(conflicted, id); | ||
| 447 | assert!( | ||
| 448 | matches!( | ||
| 449 | status, | ||
| 450 | EditStatus::Conflict(ConflictKind::TargetUnavailable) | ||
| 451 | ), | ||
| 452 | "{name}: {status:?}" | ||
| 453 | ); | ||
| 454 | assert_eq!(server.publications, 0); | ||
| 455 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 456 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 457 | drop(cache); | ||
| 458 | cache = Replica::open(&path).unwrap(); | ||
| 459 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 460 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 461 | assert_eq!(cache.status(id).unwrap(), Some(status)); | ||
| 462 | } | ||
| 463 | assert_eq!(cases, 3); | ||
| 464 | } | ||
| 465 | |||
| 466 | #[test] | ||
| 467 | fn split_join_dependencies_reopen_and_rebase_with_remote_text_and_styles() { | ||
| 468 | let (source, sid, left, parent) = fixture(); | ||
| 469 | let directory = tempfile::tempdir().unwrap(); | ||
| 470 | let path = directory.path().join("paragraph.sqlite"); | ||
| 471 | let mut cache = Replica::create(&path, &source).unwrap(); | ||
| 472 | let split = ParagraphSplit::new(left, 2, "Local").unwrap(); | ||
| 473 | let child = Insertion::paragraph(split.object(), None, "Child", "Local").unwrap(); | ||
| 474 | let join = ParagraphJoin::new(left, split.text_object(), "Local").unwrap(); | ||
| 475 | let mut ids = Vec::new(); | ||
| 476 | for step in 0..6 { | ||
| 477 | let current = cache.snapshot().unwrap(); | ||
| 478 | let id = match step { | ||
| 479 | 0 => cache.split(&current, sid, &split), | ||
| 480 | 1 => cache.edit_text(&current, sid, split.text_object(), 0..2, "🦋"), | ||
| 481 | 2 => cache.format( | ||
| 482 | &current, | ||
| 483 | sid, | ||
| 484 | split.text_object(), | ||
| 485 | 2..3, | ||
| 486 | &[TextAttribute::Bold(true)], | ||
| 487 | ), | ||
| 488 | 3 => cache.insert(&current, sid, &child), | ||
| 489 | 4 => cache.join(&current, sid, &join), | ||
| 490 | 5 => cache.edit_text(&current, sid, left, 5..6, "D"), | ||
| 491 | _ => unreachable!(), | ||
| 492 | } | ||
| 493 | .unwrap() | ||
| 494 | .unwrap(); | ||
| 495 | ids.push(id); | ||
| 496 | let saved = cache.snapshot().unwrap(); | ||
| 497 | let pending = cache.pending().unwrap(); | ||
| 498 | drop(cache); | ||
| 499 | cache = Replica::open(&path).unwrap(); | ||
| 500 | assert_eq!(cache.snapshot().unwrap(), saved); | ||
| 501 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 502 | } | ||
| 503 | let remote = onestore::replace_text(&source, sid, left, 0..0, "Z").unwrap(); | ||
| 504 | let remote = | ||
| 505 | PreparedEdit::format(&remote, sid, left, 1..2, &[TextAttribute::Italic(true)]).unwrap(); | ||
| 506 | let mut server = Server::new(remote.as_bytes()); | ||
| 507 | let local = cache.snapshot().unwrap(); | ||
| 508 | for (i, id) in ids.iter().enumerate() { | ||
| 509 | let (published, state) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 510 | assert_eq!(published, *id); | ||
| 511 | assert!(matches!(state, EditStatus::Published { .. }), "{state:?}"); | ||
| 512 | drop(cache); | ||
| 513 | cache = Replica::open(&path).unwrap(); | ||
| 514 | assert_eq!(cache.status(*id).unwrap(), Some(state)); | ||
| 515 | if i != ids.len() - 1 { | ||
| 516 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 517 | } | ||
| 518 | } | ||
| 519 | assert_eq!(server.publications, ids.len()); | ||
| 520 | assert_eq!(server.visible, server.durable); | ||
| 521 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 522 | let store = Store::parse(&server.durable).unwrap(); | ||
| 523 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 524 | index.validate_current().unwrap(); | ||
| 525 | let doc = Document::parse(&index).unwrap(); | ||
| 526 | let space = &doc.spaces[&sid]; | ||
| 527 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 528 | assert!( | ||
| 529 | matches!(&view.nodes[&left].kind, Kind::RichText { text, .. } if text == "Zab🦋cD") | ||
| 530 | ); | ||
| 531 | assert_eq!(view.nodes[&parent].children, [child.object()]); | ||
| 532 | assert_eq!(view.nodes[&parent].content, [left]); | ||
| 533 | let runs = view.text_runs(left).unwrap(); | ||
| 534 | let chars: Vec<_> = runs | ||
| 535 | .iter() | ||
| 536 | .flat_map(|run| { | ||
| 537 | run.text | ||
| 538 | .chars() | ||
| 539 | .map(|c| (c, run.format.bold, run.format.italic)) | ||
| 540 | }) | ||
| 541 | .collect(); | ||
| 542 | assert_eq!(chars.iter().find(|v| v.0 == 'a').unwrap().2, Some(true)); | ||
| 543 | assert_eq!(chars.iter().find(|v| v.0 == 'c').unwrap().1, Some(true)); | ||
| 544 | } | ||
| 545 | |||
| 546 | #[test] | ||
| 547 | fn changed_split_children_require_fresh_review_without_reallocating_dependents() { | ||
| 548 | let (source, sid, left, parent) = fixture(); | ||
| 549 | let directory = tempfile::tempdir().unwrap(); | ||
| 550 | let path = directory.path().join("paragraph.sqlite"); | ||
| 551 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 552 | let split = ParagraphSplit::new(left, 2, "Local").unwrap(); | ||
| 553 | let id = cache.split(&source, sid, &split).unwrap().unwrap(); | ||
| 554 | let dependent = cache | ||
| 555 | .edit_text( | ||
| 556 | &cache.snapshot().unwrap(), | ||
| 557 | sid, | ||
| 558 | split.text_object(), | ||
| 559 | 0..2, | ||
| 560 | "🦋", | ||
| 561 | ) | ||
| 562 | .unwrap() | ||
| 563 | .unwrap(); | ||
| 564 | let local = cache.snapshot().unwrap(); | ||
| 565 | let child = Insertion::paragraph(parent, None, "Remote child", "Remote").unwrap(); | ||
| 566 | let remote = PreparedEdit::insert(&source, sid, &child).unwrap(); | ||
| 567 | let mut server = Server::new(remote.as_bytes()); | ||
| 568 | assert_eq!( | ||
| 569 | cache.sync_once(&mut server).unwrap(), | ||
| 570 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | ||
| 571 | ); | ||
| 572 | assert_eq!(server.publications, 0); | ||
| 573 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 574 | assert!( | ||
| 575 | cache | ||
| 576 | .rebase_conflict(id, &source, &server.visible, 2..2) | ||
| 577 | .is_err() | ||
| 578 | ); | ||
| 579 | assert!( | ||
| 580 | cache | ||
| 581 | .rebase_conflict(id, &local, &server.visible, 2..3) | ||
| 582 | .is_err() | ||
| 583 | ); | ||
| 584 | cache | ||
| 585 | .rebase_conflict(id, &local, &server.visible, 2..2) | ||
| 586 | .unwrap(); | ||
| 587 | let pending = cache.pending().unwrap(); | ||
| 588 | let onestore_offline::Operation::Split(edit) = &pending[0].operation else { | ||
| 589 | panic!() | ||
| 590 | }; | ||
| 591 | assert_eq!(edit.intent, split); | ||
| 592 | assert_eq!(pending[1].id, dependent); | ||
| 593 | drop(cache); | ||
| 594 | let cache = Replica::open(&path).unwrap(); | ||
| 595 | for expected in [id, dependent] { | ||
| 596 | let (actual, state) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 597 | assert_eq!(actual, expected); | ||
| 598 | assert!(matches!(state, EditStatus::Published { .. })); | ||
| 599 | } | ||
| 600 | let store = Store::parse(&server.durable).unwrap(); | ||
| 601 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 602 | let doc = Document::parse(&index).unwrap(); | ||
| 603 | let space = &doc.spaces[&sid]; | ||
| 604 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 605 | assert_eq!(view.nodes[&split.object()].children, [child.object()]); | ||
| 606 | assert!( | ||
| 607 | matches!(&view.nodes[&split.text_object()].kind, Kind::RichText { text, .. } if text == "🦋cd") | ||
| 608 | ); | ||
| 609 | } | ||
| 610 | |||
| 611 | #[test] | ||
| 612 | fn uncertain_paragraph_operations_keep_the_original_attempt_across_reopen() { | ||
| 613 | for join in [false, true] { | ||
| 614 | for fault in [ | ||
| 615 | Fault::Before, | ||
| 616 | Fault::UnknownBefore, | ||
| 617 | Fault::UnknownAfter, | ||
| 618 | Fault::Committed, | ||
| 619 | ] { | ||
| 620 | let (source, sid, left, _) = fixture(); | ||
| 621 | let split = ParagraphSplit::new(left, 2, "Local").unwrap(); | ||
| 622 | let source = if join { | ||
| 623 | PreparedEdit::split(&source, sid, &split) | ||
| 624 | .unwrap() | ||
| 625 | .as_bytes() | ||
| 626 | .to_vec() | ||
| 627 | } else { | ||
| 628 | source | ||
| 629 | }; | ||
| 630 | let directory = tempfile::tempdir().unwrap(); | ||
| 631 | let path = directory.path().join("paragraph.sqlite"); | ||
| 632 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 633 | let id = if join { | ||
| 634 | cache.join( | ||
| 635 | &source, | ||
| 636 | sid, | ||
| 637 | &ParagraphJoin::new(left, split.text_object(), "Local").unwrap(), | ||
| 638 | ) | ||
| 639 | } else { | ||
| 640 | cache.split(&source, sid, &split) | ||
| 641 | } | ||
| 642 | .unwrap() | ||
| 643 | .unwrap(); | ||
| 644 | let local = cache.snapshot().unwrap(); | ||
| 645 | let intent = cache.pending().unwrap(); | ||
| 646 | let mut server = Server::new(&source); | ||
| 647 | server.fault = fault; | ||
| 648 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 649 | let state = cache.status(id).unwrap().unwrap(); | ||
| 650 | drop(cache); | ||
| 651 | let cache = Replica::open(&path).unwrap(); | ||
| 652 | assert_eq!(cache.status(id).unwrap(), Some(state)); | ||
| 653 | if matches!(fault, Fault::UnknownBefore | Fault::UnknownAfter) { | ||
| 654 | assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); | ||
| 655 | assert_eq!(cache.pending().unwrap(), intent); | ||
| 656 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 657 | if matches!(fault, Fault::UnknownBefore) { | ||
| 658 | assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, state))); | ||
| 659 | assert_eq!(server.publications, 1); | ||
| 660 | continue; | ||
| 661 | } | ||
| 662 | server.fault = Fault::Confirm; | ||
| 663 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 664 | assert_eq!(cache.status(id).unwrap(), Some(state)); | ||
| 665 | let (_, state) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 666 | assert!(matches!(state, EditStatus::Published { .. })); | ||
| 667 | assert_eq!(server.publications, 1); | ||
| 668 | } else if matches!(fault, Fault::Before) { | ||
| 669 | assert_eq!(state, EditStatus::Pending); | ||
| 670 | assert!(matches!( | ||
| 671 | cache.sync_once(&mut server).unwrap().unwrap().1, | ||
| 672 | EditStatus::Published { .. } | ||
| 673 | )); | ||
| 674 | assert_eq!(server.publications, 2); | ||
| 675 | } else { | ||
| 676 | assert!(matches!(state, EditStatus::Published { .. })); | ||
| 677 | assert_eq!(server.publications, 1); | ||
| 678 | } | ||
| 679 | assert!(cache.pending().unwrap().is_empty()); | ||
| 680 | let saved = cache.snapshot().unwrap(); | ||
| 681 | if matches!(fault, Fault::UnknownAfter) { | ||
| 682 | // Confirmation refreshes version metadata after reading the acknowledged snapshot. | ||
| 683 | assert!(saved[..212] == server.durable[..212]); | ||
| 684 | assert!(saved[252..] == server.durable[252..]); | ||
| 685 | assert_eq!( | ||
| 686 | Store::parse(&server.durable).unwrap().header.generation, | ||
| 687 | Store::parse(&saved).unwrap().header.generation + 1 | ||
| 688 | ); | ||
| 689 | } else { | ||
| 690 | assert!(saved == server.durable); | ||
| 691 | } | ||
| 692 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | ||
| 693 | assert!(cache.snapshot().unwrap() == server.durable); | ||
| 694 | } | ||
| 695 | } | ||
| 696 | } | ||
| 697 | |||
| 698 | #[test] | ||
| 699 | fn join_reviews_preserve_survivors_and_require_current_child_placement() { | ||
| 700 | for empty in [false, true] { | ||
| 701 | let (source, sid, left, _) = fixture(); | ||
| 702 | let split = ParagraphSplit::new(left, if empty { 0 } else { 2 }, "Local").unwrap(); | ||
| 703 | let source = PreparedEdit::split(&source, sid, &split) | ||
| 704 | .unwrap() | ||
| 705 | .as_bytes() | ||
| 706 | .to_vec(); | ||
| 707 | let directory = tempfile::tempdir().unwrap(); | ||
| 708 | let path = directory.path().join("join.sqlite"); | ||
| 709 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 710 | let intent = ParagraphJoin::new(left, split.text_object(), "Local").unwrap(); | ||
| 711 | let id = cache.join(&source, sid, &intent).unwrap().unwrap(); | ||
| 712 | let survivor = if empty { split.text_object() } else { left }; | ||
| 713 | let dependent = cache | ||
| 714 | .edit_text(&cache.snapshot().unwrap(), sid, survivor, 0..0, "Local ") | ||
| 715 | .unwrap() | ||
| 716 | .unwrap(); | ||
| 717 | let local = cache.snapshot().unwrap(); | ||
| 718 | let child = | ||
| 719 | Insertion::paragraph(split.object(), None, "Remote child", "Remote").unwrap(); | ||
| 720 | let remote = PreparedEdit::insert(&source, sid, &child) | ||
| 721 | .unwrap() | ||
| 722 | .as_bytes() | ||
| 723 | .to_vec(); | ||
| 724 | let mut server = Server::new(&remote); | ||
| 725 | assert_eq!( | ||
| 726 | cache.sync_once(&mut server).unwrap(), | ||
| 727 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | ||
| 728 | ); | ||
| 729 | assert!(cache.rebase_join_conflict(id, &source, &remote).is_err()); | ||
| 730 | assert_eq!(server.publications, 0); | ||
| 731 | cache.rebase_join_conflict(id, &local, &remote).unwrap(); | ||
| 732 | assert!( | ||
| 733 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | ||
| 734 | ); | ||
| 735 | assert!( | ||
| 736 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == dependent) | ||
| 737 | ); | ||
| 738 | let store = Store::parse(&server.durable).unwrap(); | ||
| 739 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 740 | let doc = Document::parse(&index).unwrap(); | ||
| 741 | let space = &doc.spaces[&sid]; | ||
| 742 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 743 | let parent = view | ||
| 744 | .nodes | ||
| 745 | .values() | ||
| 746 | .find(|node| node.content == [survivor]) | ||
| 747 | .unwrap(); | ||
| 748 | assert_eq!(parent.children, [child.object()]); | ||
| 749 | assert!( | ||
| 750 | matches!(&view.nodes[&survivor].kind, Kind::RichText { text, .. } if text == "Local ab🦀cd") | ||
| 751 | ); | ||
| 752 | } | ||
| 753 | |||
| 754 | let (source, sid, left, _) = fixture(); | ||
| 755 | let split = ParagraphSplit::new(left, 0, "Local").unwrap(); | ||
| 756 | let source = PreparedEdit::split(&source, sid, &split) | ||
| 757 | .unwrap() | ||
| 758 | .as_bytes() | ||
| 759 | .to_vec(); | ||
| 760 | let directory = tempfile::tempdir().unwrap(); | ||
| 761 | let path = directory.path().join("identity.sqlite"); | ||
| 762 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 763 | let intent = ParagraphJoin::new(left, split.text_object(), "Local").unwrap(); | ||
| 764 | let id = cache.join(&source, sid, &intent).unwrap().unwrap(); | ||
| 765 | let local = cache.snapshot().unwrap(); | ||
| 766 | let remote = onestore::replace_text(&source, sid, left, 0..0, "Remote").unwrap(); | ||
| 767 | let mut server = Server::new(&remote); | ||
| 768 | assert_eq!( | ||
| 769 | cache.sync_once(&mut server).unwrap(), | ||
| 770 | Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) | ||
| 771 | ); | ||
| 772 | assert!(cache.rebase_join_conflict(id, &local, &remote).is_err()); | ||
| 773 | assert!(cache.snapshot().unwrap() == local); | ||
| 774 | assert_eq!(server.publications, 0); | ||
| 775 | assert_eq!(cache.pending().unwrap().len(), 1); | ||
| 776 | } | ||
| 777 | |||
| 778 | #[test] | ||
| 779 | fn overlapping_clients_retain_each_local_branch_without_replaying_removed_targets() { | ||
| 780 | let (source, sid, left, _) = fixture(); | ||
| 781 | let split = ParagraphSplit::new(left, 2, "Seed").unwrap(); | ||
| 782 | let source = PreparedEdit::split(&source, sid, &split) | ||
| 783 | .unwrap() | ||
| 784 | .as_bytes() | ||
| 785 | .to_vec(); | ||
| 786 | let directory = tempfile::tempdir().unwrap(); | ||
| 787 | let first = Replica::create(directory.path().join("first.sqlite"), &source).unwrap(); | ||
| 788 | let second_path = directory.path().join("second.sqlite"); | ||
| 789 | let second = Replica::create(&second_path, &source).unwrap(); | ||
| 790 | let join = ParagraphJoin::new(left, split.text_object(), "First").unwrap(); | ||
| 791 | first.join(&source, sid, &join).unwrap(); | ||
| 792 | let right_split = ParagraphSplit::new(split.text_object(), 2, "Second").unwrap(); | ||
| 793 | let id = second.split(&source, sid, &right_split).unwrap().unwrap(); | ||
| 794 | second | ||
| 795 | .edit_text( | ||
| 796 | &second.snapshot().unwrap(), | ||
| 797 | sid, | ||
| 798 | right_split.text_object(), | ||
| 799 | 0..0, | ||
| 800 | "Second ", | ||
| 801 | ) | ||
| 802 | .unwrap(); | ||
| 803 | let local = second.snapshot().unwrap(); | ||
| 804 | let pending = second.pending().unwrap(); | ||
| 805 | let mut server = Server::new(&source); | ||
| 806 | assert!(matches!( | ||
| 807 | first.sync_once(&mut server).unwrap().unwrap().1, | ||
| 808 | EditStatus::Published { .. } | ||
| 809 | )); | ||
| 810 | assert_eq!( | ||
| 811 | second.sync_once(&mut server).unwrap(), | ||
| 812 | Some((id, EditStatus::Conflict(ConflictKind::TargetUnavailable))) | ||
| 813 | ); | ||
| 814 | drop(second); | ||
| 815 | let second = Replica::open(&second_path).unwrap(); | ||
| 816 | assert_eq!(second.pending().unwrap(), pending); | ||
| 817 | assert!(second.snapshot().unwrap() == local); | ||
| 818 | assert_eq!(server.publications, 1); | ||
| 819 | assert_eq!( | ||
| 820 | second.status(id).unwrap(), | ||
| 821 | Some(EditStatus::Conflict(ConflictKind::TargetUnavailable)) | ||
| 822 | ); | ||
| 823 | } | ||
| 824 | |||
| 825 | #[test] | ||
| 826 | #[ignore = "exports reconciled paragraph edits for independent native validation"] | ||
| 827 | fn export_native_offline_paragraphs() { | ||
| 828 | use std::{fs, path::PathBuf}; | ||
| 829 | let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/paragraph-edit"); | ||
| 830 | let output = PathBuf::from(std::env::var_os("ONESTORE_OFFLINE_PARAGRAPH_OUTPUT").unwrap()); | ||
| 831 | assert!(output.is_absolute()); | ||
| 832 | fs::create_dir(&output).unwrap(); | ||
| 833 | for (name, source, manifest, split) in [ | ||
| 834 | ( | ||
| 835 | "splits", | ||
| 836 | "before/notebook/synthetic.one", | ||
| 837 | "rust-split/manifest.json", | ||
| 838 | true, | ||
| 839 | ), | ||
| 840 | ( | ||
| 841 | "joins", | ||
| 842 | "split/notebook/synthetic.one", | ||
| 843 | "rust-join/split/manifest.json", | ||
| 844 | false, | ||
| 845 | ), | ||
| 846 | ( | ||
| 847 | "inheritance", | ||
| 848 | "join-edges/before/notebook/synthetic.one", | ||
| 849 | "rust-join/inheritance/manifest.json", | ||
| 850 | false, | ||
| 851 | ), | ||
| 852 | ( | ||
| 853 | "tags", | ||
| 854 | "join-tags/before/notebook/synthetic.one", | ||
| 855 | "rust-join/tags/manifest.json", | ||
| 856 | false, | ||
| 857 | ), | ||
| 858 | ] { | ||
| 859 | let source = fs::read(root.join(source)).unwrap(); | ||
| 860 | let folder = output.join(name); | ||
| 861 | fs::create_dir(&folder).unwrap(); | ||
| 862 | let path = folder.join("cache.sqlite"); | ||
| 863 | let mut cache = Replica::create(&path, &source).unwrap(); | ||
| 864 | let mut server = Server::new(&source); | ||
| 865 | let manifest: serde_json::Value = | ||
| 866 | serde_json::from_slice(&fs::read(root.join(manifest)).unwrap()).unwrap(); | ||
| 867 | let cases = if split { &manifest["cases"] } else { &manifest }; | ||
| 868 | let mut recorded = Vec::new(); | ||
| 869 | for case in cases | ||
| 870 | .as_array() | ||
| 871 | .unwrap() | ||
| 872 | .iter() | ||
| 873 | .filter(|case| case.get("intent").is_some()) | ||
| 874 | { | ||
| 875 | let local = cache.snapshot().unwrap(); | ||
| 876 | let (text, offset) = if split { | ||
| 877 | serde_json::from_value::<ParagraphSplit>(case["intent"].clone()) | ||
| 878 | .unwrap() | ||
| 879 | .position() | ||
| 880 | } else { | ||
| 881 | ( | ||
| 882 | serde_json::from_value::<ParagraphJoin>(case["intent"].clone()) | ||
| 883 | .unwrap() | ||
| 884 | .texts()[0], | ||
| 885 | 1, | ||
| 886 | ) | ||
| 887 | }; | ||
| 888 | let store = Store::parse(&local).unwrap(); | ||
| 889 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 890 | let doc = Document::parse(&index).unwrap(); | ||
| 891 | let (sid, space) = doc | ||
| 892 | .spaces | ||
| 893 | .iter() | ||
| 894 | .find(|(_, space)| { | ||
| 895 | space.revisions[&space.contexts[&ExGuid::default()]] | ||
| 896 | .nodes | ||
| 897 | .contains_key(&text) | ||
| 898 | }) | ||
| 899 | .unwrap(); | ||
| 900 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 901 | let Kind::RichText { text: content, .. } = &view.nodes[&text].kind else { | ||
| 902 | panic!() | ||
| 903 | }; | ||
| 904 | let remote_prefix = offset > 0 && !content.is_empty(); | ||
| 905 | assert!(!content.contains('☂')); | ||
| 906 | if remote_prefix { | ||
| 907 | server.visible = | ||
| 908 | onestore::replace_text(&server.visible, *sid, text, 0..0, "☂").unwrap(); | ||
| 909 | server.durable.clone_from(&server.visible); | ||
| 910 | } | ||
| 911 | let id = if split { | ||
| 912 | cache.split( | ||
| 913 | &local, | ||
| 914 | *sid, | ||
| 915 | &serde_json::from_value(case["intent"].clone()).unwrap(), | ||
| 916 | ) | ||
| 917 | } else { | ||
| 918 | cache.join( | ||
| 919 | &local, | ||
| 920 | *sid, | ||
| 921 | &serde_json::from_value(case["intent"].clone()).unwrap(), | ||
| 922 | ) | ||
| 923 | } | ||
| 924 | .unwrap() | ||
| 925 | .unwrap(); | ||
| 926 | recorded.push(serde_json::json!({"case": case["case"], "id": id, | ||
| 927 | "space": sid, "intent": case["intent"], "remote_prefix": remote_prefix})); | ||
| 928 | drop(cache); | ||
| 929 | cache = Replica::open(&path).unwrap(); | ||
| 930 | } | ||
| 931 | for case in &mut recorded { | ||
| 932 | let id = case["id"].as_u64().unwrap(); | ||
| 933 | if id % 2 == 0 { | ||
| 934 | server.fault = Fault::UnknownAfter; | ||
| 935 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 936 | assert!(matches!( | ||
| 937 | cache.status(id).unwrap(), | ||
| 938 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 939 | )); | ||
| 940 | drop(cache); | ||
| 941 | cache = Replica::open(&path).unwrap(); | ||
| 942 | } | ||
| 943 | let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 944 | assert_eq!(actual, id); | ||
| 945 | let EditStatus::Published { revision } = status else { | ||
| 946 | panic!("{name}: {status:?}") | ||
| 947 | }; | ||
| 948 | case["revision"] = serde_json::to_value(revision).unwrap(); | ||
| 949 | } | ||
| 950 | assert_eq!(server.publications, recorded.len()); | ||
| 951 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | ||
| 952 | assert!(cache.snapshot().unwrap() == server.durable); | ||
| 953 | cache | ||
| 954 | .export_recovery(folder.join("recovery.sqlite")) | ||
| 955 | .unwrap(); | ||
| 956 | fs::create_dir(folder.join("candidate")).unwrap(); | ||
| 957 | fs::write(folder.join("candidate/synthetic.one"), &server.durable).unwrap(); | ||
| 958 | fs::write( | ||
| 959 | folder.join("manifest.json"), | ||
| 960 | serde_json::to_vec_pretty(&recorded).unwrap(), | ||
| 961 | ) | ||
| 962 | .unwrap(); | ||
| 963 | } | ||
| 964 | } | ||
| 965 | } | ||
| 966 | |||
| 967 | #[derive(Clone, Copy, Default)] | ||
| 968 | enum Fault { | ||
| 969 | #[default] | ||
| 970 | None, | ||
| 971 | Before, | ||
| 972 | UnknownBefore, | ||
| 973 | UnknownAfter, | ||
| 974 | Committed, | ||
| 975 | PanicBefore, | ||
| 976 | PanicAfter, | ||
| 977 | Confirm, | ||
| 978 | ConfirmCommitted, | ||
| 979 | } | ||
| 980 | |||
| 981 | struct Server { | ||
| 982 | visible: Vec<u8>, | ||
| 983 | durable: Vec<u8>, | ||
| 984 | fault: Fault, | ||
| 985 | publications: usize, | ||
| 986 | confirmations: usize, | ||
| 987 | } | ||
| 988 | |||
| 989 | impl Server { | ||
| 990 | fn new(source: &[u8]) -> Self { | ||
| 991 | Self { | ||
| 992 | visible: source.to_vec(), | ||
| 993 | durable: source.to_vec(), | ||
| 994 | fault: Fault::None, | ||
| 995 | publications: 0, | ||
| 996 | confirmations: 0, | ||
| 997 | } | ||
| 998 | } | ||
| 999 | } | ||
| 1000 | |||
| 1001 | fn failure(state: CommitState) -> CommitError { | ||
| 1002 | CommitError { | ||
| 1003 | state, | ||
| 1004 | error: io::Error::from(io::ErrorKind::ConnectionAborted), | ||
| 1005 | } | ||
| 1006 | } | ||
| 1007 | |||
| 1008 | impl CommitIo for Server { | ||
| 1009 | fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> { | ||
| 1010 | let offset = usize::try_from(offset).unwrap(); | ||
| 1011 | let size = output.len().min(self.visible.len().saturating_sub(offset)); | ||
| 1012 | if size > 0 { | ||
| 1013 | output[..size].copy_from_slice(&self.visible[offset..offset + size]); | ||
| 1014 | } | ||
| 1015 | Ok(size) | ||
| 1016 | } | ||
| 1017 | fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> { | ||
| 1018 | let offset = usize::try_from(offset).unwrap(); | ||
| 1019 | self.visible | ||
| 1020 | .resize(self.visible.len().max(offset + bytes.len()), 0); | ||
| 1021 | self.visible[offset..offset + bytes.len()].copy_from_slice(bytes); | ||
| 1022 | Ok(bytes.len()) | ||
| 1023 | } | ||
| 1024 | fn flush(&mut self) -> io::Result<()> { | ||
| 1025 | self.durable.clone_from(&self.visible); | ||
| 1026 | Ok(()) | ||
| 1027 | } | ||
| 1028 | } | ||
| 1029 | |||
| 1030 | impl Remote for Server { | ||
| 1031 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 1032 | Ok(self.visible.clone()) | ||
| 1033 | } | ||
| 1034 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 1035 | self.publications += 1; | ||
| 1036 | let fault = std::mem::take(&mut self.fault); | ||
| 1037 | match fault { | ||
| 1038 | Fault::Before => return Err(failure(CommitState::NotCommitted)), | ||
| 1039 | Fault::UnknownBefore => return Err(failure(CommitState::Unknown)), | ||
| 1040 | Fault::PanicBefore => panic!("Terminated before remote I/O"), | ||
| 1041 | _ => {} | ||
| 1042 | } | ||
| 1043 | let old = self.durable.clone(); | ||
| 1044 | edit.commit(self)?; | ||
| 1045 | match fault { | ||
| 1046 | Fault::UnknownAfter => { | ||
| 1047 | self.durable = old; | ||
| 1048 | Err(failure(CommitState::Unknown)) | ||
| 1049 | } | ||
| 1050 | Fault::Committed => Err(failure(CommitState::Committed)), | ||
| 1051 | Fault::PanicAfter => panic!("Terminated after remote publication"), | ||
| 1052 | _ => Ok(()), | ||
| 1053 | } | ||
| 1054 | } | ||
| 1055 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 1056 | self.confirmations += 1; | ||
| 1057 | if matches!(self.fault, Fault::Confirm) { | ||
| 1058 | self.fault = Fault::None; | ||
| 1059 | return Err(failure(CommitState::Unknown)); | ||
| 1060 | } | ||
| 1061 | onestore::confirm_snapshot(self, snapshot)?; | ||
| 1062 | if matches!(self.fault, Fault::ConfirmCommitted) { | ||
| 1063 | self.fault = Fault::None; | ||
| 1064 | return Err(failure(CommitState::Committed)); | ||
| 1065 | } | ||
| 1066 | Ok(()) | ||
| 1067 | } | ||
| 1068 | } | ||
| 1069 | |||
| 1070 | fn text(source: &[u8]) -> (ExGuid, ExGuid, String) { | ||
| 1071 | let store = Store::parse(source).unwrap(); | ||
| 1072 | assert!(store.checksum_mismatches.is_empty()); | ||
| 1073 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 1074 | index.validate_current().unwrap(); | ||
| 1075 | let doc = Document::parse(&index).unwrap(); | ||
| 1076 | doc.spaces | ||
| 1077 | .iter() | ||
| 1078 | .find_map(|(sid, space)| { | ||
| 1079 | space.revisions[&space.contexts[&ExGuid::default()]] | ||
| 1080 | .nodes | ||
| 1081 | .iter() | ||
| 1082 | .find_map(|(oid, node)| match &node.kind { | ||
| 1083 | Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())), | ||
| 1084 | _ => None, | ||
| 1085 | }) | ||
| 1086 | }) | ||
| 1087 | .unwrap() | ||
| 1088 | } | ||
| 1089 | |||
| 1090 | #[test] | ||
| 1091 | fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becoming_a_writer() { | ||
| 1092 | use onestore::{Insertion, TextAttribute}; | ||
| 1093 | use onestore_offline::{Recovery, RecoverySummary}; | ||
| 1094 | |||
| 1095 | let directory = tempfile::tempdir().unwrap(); | ||
| 1096 | let path = directory.path().join("live.sqlite"); | ||
| 1097 | let archive_path = directory.path().join("recovery.sqlite"); | ||
| 1098 | let source = onestore::create_section("recovery.one", "Original", "Fixture").unwrap(); | ||
| 1099 | let (space, object, _) = text(&source); | ||
| 1100 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1101 | let published = cache | ||
| 1102 | .edit_text(&source, space, object, 0..0, "Published ") | ||
| 1103 | .unwrap() | ||
| 1104 | .unwrap(); | ||
| 1105 | let mut server = Server::new(&source); | ||
| 1106 | let (_, receipt) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 1107 | let source = cache.snapshot().unwrap(); | ||
| 1108 | let store = Store::parse(&source).unwrap(); | ||
| 1109 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 1110 | let document = Document::parse(&index).unwrap(); | ||
| 1111 | let (page_space, page) = document.pages().unwrap()[0]; | ||
| 1112 | let outline = Insertion::outline(page, 100.0, 200.0, "Outline", "Fixture").unwrap(); | ||
| 1113 | let inserted = cache | ||
| 1114 | .insert(&source, page_space, &outline) | ||
| 1115 | .unwrap() | ||
| 1116 | .unwrap(); | ||
| 1117 | let paragraph = Insertion::paragraph(outline.object(), None, "Recovery 🦀", "Fixture").unwrap(); | ||
| 1118 | cache | ||
| 1119 | .insert(&cache.snapshot().unwrap(), page_space, &paragraph) | ||
| 1120 | .unwrap(); | ||
| 1121 | cache | ||
| 1122 | .format( | ||
| 1123 | &cache.snapshot().unwrap(), | ||
| 1124 | page_space, | ||
| 1125 | paragraph.text_object(), | ||
| 1126 | 0..3, | ||
| 1127 | &[TextAttribute::Bold(true)], | ||
| 1128 | ) | ||
| 1129 | .unwrap(); | ||
| 1130 | cache | ||
| 1131 | .edit_text( | ||
| 1132 | &cache.snapshot().unwrap(), | ||
| 1133 | page_space, | ||
| 1134 | paragraph.text_object(), | ||
| 1135 | 0..0, | ||
| 1136 | "Pending ", | ||
| 1137 | ) | ||
| 1138 | .unwrap(); | ||
| 1139 | server.fault = Fault::UnknownBefore; | ||
| 1140 | assert!(matches!( | ||
| 1141 | cache.sync_once(&mut server), | ||
| 1142 | Err(Error::Remote(CommitError { | ||
| 1143 | state: CommitState::Unknown, | ||
| 1144 | .. | ||
| 1145 | })) | ||
| 1146 | )); | ||
| 1147 | let working = cache.snapshot().unwrap(); | ||
| 1148 | let remote = cache.remote_snapshot().unwrap(); | ||
| 1149 | let pending = cache.pending().unwrap(); | ||
| 1150 | let uncertain = cache.status(inserted).unwrap(); | ||
| 1151 | let source_file = std::fs::read(&path).unwrap(); | ||
| 1152 | let summary = RecoverySummary { | ||
| 1153 | queued_edits: 4, | ||
| 1154 | conflicts: 0, | ||
| 1155 | uncertain_edits: 1, | ||
| 1156 | published_receipts: 1, | ||
| 1157 | working_bytes: working.len() as u64, | ||
| 1158 | remote_bytes: remote.len() as u64, | ||
| 1159 | cached_assets: 0, | ||
| 1160 | cached_asset_bytes: 0, | ||
| 1161 | }; | ||
| 1162 | assert_eq!(cache.recovery_summary().unwrap(), summary); | ||
| 1163 | cache.export_recovery(&archive_path).unwrap(); | ||
| 1164 | assert_eq!(std::fs::read(&path).unwrap(), source_file); | ||
| 1165 | let archive_bytes = std::fs::read(&archive_path).unwrap(); | ||
| 1166 | assert!(Replica::open(&archive_path).is_err()); | ||
| 1167 | assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes); | ||
| 1168 | assert!(Recovery::open(&path).is_err()); | ||
| 1169 | let archive = Recovery::open(&archive_path).unwrap(); | ||
| 1170 | assert_eq!(archive.summary().unwrap(), summary); | ||
| 1171 | assert_eq!(archive.snapshot().unwrap(), working); | ||
| 1172 | assert_eq!(archive.remote_snapshot().unwrap(), remote); | ||
| 1173 | assert_eq!(archive.pending().unwrap(), pending); | ||
| 1174 | assert_eq!(archive.status(published).unwrap(), Some(receipt)); | ||
| 1175 | assert_eq!(archive.status(inserted).unwrap(), uncertain); | ||
| 1176 | for edit in &pending { | ||
| 1177 | assert_eq!( | ||
| 1178 | archive.status(edit.id).unwrap(), | ||
| 1179 | cache.status(edit.id).unwrap() | ||
| 1180 | ); | ||
| 1181 | } | ||
| 1182 | let EditStatus::Published { revision } = receipt else { | ||
| 1183 | panic!() | ||
| 1184 | }; | ||
| 1185 | assert_eq!(archive.receipts().unwrap(), [(published, revision)].into()); | ||
| 1186 | assert_eq!( | ||
| 1187 | archive.status(u64::MAX - 1).unwrap_err().to_string(), | ||
| 1188 | cache.status(u64::MAX - 1).unwrap_err().to_string() | ||
| 1189 | ); | ||
| 1190 | for existing in [&path, &archive_path] { | ||
| 1191 | assert!( | ||
| 1192 | matches!(cache.export_recovery(existing), Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists) | ||
| 1193 | ); | ||
| 1194 | } | ||
| 1195 | assert_eq!(std::fs::read(&path).unwrap(), source_file); | ||
| 1196 | assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes); | ||
| 1197 | cache | ||
| 1198 | .edit_text(&working, space, object, 0..0, "Later ") | ||
| 1199 | .unwrap(); | ||
| 1200 | assert_eq!(archive.snapshot().unwrap(), working); | ||
| 1201 | assert_eq!(archive.pending().unwrap(), pending); | ||
| 1202 | drop(archive); | ||
| 1203 | assert_eq!( | ||
| 1204 | Recovery::open(&archive_path).unwrap().summary().unwrap(), | ||
| 1205 | summary | ||
| 1206 | ); | ||
| 1207 | assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes); | ||
| 1208 | #[cfg(unix)] | ||
| 1209 | { | ||
| 1210 | use std::os::unix::fs::PermissionsExt; | ||
| 1211 | assert_eq!( | ||
| 1212 | std::fs::metadata(&archive_path) | ||
| 1213 | .unwrap() | ||
| 1214 | .permissions() | ||
| 1215 | .mode() | ||
| 1216 | & 0o777, | ||
| 1217 | 0o600 | ||
| 1218 | ); | ||
| 1219 | } | ||
| 1220 | let remaining: Vec<_> = std::fs::read_dir(directory.path()) | ||
| 1221 | .unwrap() | ||
| 1222 | .map(|entry| entry.unwrap().file_name()) | ||
| 1223 | .collect(); | ||
| 1224 | assert!( | ||
| 1225 | remaining | ||
| 1226 | .iter() | ||
| 1227 | .all(|name| !name.to_string_lossy().starts_with(".onestore-recovery-")), | ||
| 1228 | "Temporary recovery files remain: {remaining:?}" | ||
| 1229 | ); | ||
| 1230 | } | ||
| 1231 | |||
| 1232 | #[test] | ||
| 1233 | fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archives() { | ||
| 1234 | use onestore_offline::Recovery; | ||
| 1235 | |||
| 1236 | let directory = tempfile::tempdir().unwrap(); | ||
| 1237 | let source = onestore::create_section("recovery.one", "Original", "Fixture").unwrap(); | ||
| 1238 | let (space, object, _) = text(&source); | ||
| 1239 | let cache = Replica::create(directory.path().join("live.sqlite"), &source).unwrap(); | ||
| 1240 | let id = cache | ||
| 1241 | .edit_text(&source, space, object, 0..8, "Local") | ||
| 1242 | .unwrap() | ||
| 1243 | .unwrap(); | ||
| 1244 | let changed = onestore::replace_text(&source, space, object, 0..8, "Remote").unwrap(); | ||
| 1245 | let mut server = Server::new(&changed); | ||
| 1246 | let outcome = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 1247 | assert_eq!( | ||
| 1248 | outcome, | ||
| 1249 | (id, EditStatus::Conflict(ConflictKind::TextChanged)) | ||
| 1250 | ); | ||
| 1251 | let path = directory.path().join("conflict.sqlite"); | ||
| 1252 | cache.export_recovery(&path).unwrap(); | ||
| 1253 | let archive = Recovery::open(&path).unwrap(); | ||
| 1254 | assert_eq!(text(&archive.snapshot().unwrap()).2, "Local"); | ||
| 1255 | assert_eq!(archive.remote_snapshot().unwrap(), changed); | ||
| 1256 | assert_eq!(archive.status(id).unwrap(), Some(outcome.1)); | ||
| 1257 | assert_eq!(archive.summary().unwrap().conflicts, 1); | ||
| 1258 | assert_eq!(archive.summary().unwrap().uncertain_edits, 0); | ||
| 1259 | drop(archive); | ||
| 1260 | for sql in [ | ||
| 1261 | "PRAGMA user_version=99", | ||
| 1262 | "PRAGMA user_version=4; PRAGMA application_id=0", | ||
| 1263 | ] { | ||
| 1264 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 1265 | connection.execute_batch(sql).unwrap(); | ||
| 1266 | drop(connection); | ||
| 1267 | let before = std::fs::read(&path).unwrap(); | ||
| 1268 | assert!(Recovery::open(&path).is_err()); | ||
| 1269 | assert_eq!(std::fs::read(&path).unwrap(), before); | ||
| 1270 | } | ||
| 1271 | assert_eq!(cache.status(id).unwrap(), Some(outcome.1)); | ||
| 1272 | assert_eq!(text(&cache.snapshot().unwrap()).2, "Local"); | ||
| 1273 | } | ||
| 1274 | |||
| 1275 | #[test] | ||
| 1276 | fn rebases_multiple_disjoint_remote_changes_and_persists_the_remote_receipt() { | ||
| 1277 | let dir = tempfile::tempdir().unwrap(); | ||
| 1278 | let path = dir.path().join("cache.sqlite"); | ||
| 1279 | let source = onestore::create_section("sync.one", "ab🦀cd", "Fixture").unwrap(); | ||
| 1280 | let (sid, oid, _) = text(&source); | ||
| 1281 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1282 | let id = cache | ||
| 1283 | .edit_text(&source, sid, oid, 2..4, "🐈") | ||
| 1284 | .unwrap() | ||
| 1285 | .unwrap(); | ||
| 1286 | let remote = onestore::replace_text(&source, sid, oid, 0..6, "Xab🦀cYd").unwrap(); | ||
| 1287 | let mut server = Server::new(&remote); | ||
| 1288 | let outcome = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 1289 | assert_eq!(outcome.0, id); | ||
| 1290 | assert!(matches!(outcome.1, EditStatus::Published { .. })); | ||
| 1291 | assert_eq!(text(&server.durable).2, "Xab🐈cYd"); | ||
| 1292 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 1293 | assert!(cache.pending().unwrap().is_empty()); | ||
| 1294 | drop(cache); | ||
| 1295 | let cache = Replica::open(&path).unwrap(); | ||
| 1296 | assert_eq!(cache.status(id).unwrap(), Some(outcome.1)); | ||
| 1297 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | ||
| 1298 | assert_eq!(server.publications, 1); | ||
| 1299 | assert_eq!(cache.status(id + 1).unwrap(), None); | ||
| 1300 | let source = cache.snapshot().unwrap(); | ||
| 1301 | let next = cache | ||
| 1302 | .edit_text(&source, sid, oid, 0..0, "Later ") | ||
| 1303 | .unwrap() | ||
| 1304 | .unwrap(); | ||
| 1305 | assert!(next > id); | ||
| 1306 | } | ||
| 1307 | |||
| 1308 | #[test] | ||
| 1309 | fn overlapping_changes_preserve_both_images_and_survive_restart() { | ||
| 1310 | let dir = tempfile::tempdir().unwrap(); | ||
| 1311 | let path = dir.path().join("cache.sqlite"); | ||
| 1312 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1313 | let (sid, oid, _) = text(&source); | ||
| 1314 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1315 | let id = cache | ||
| 1316 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 1317 | .unwrap() | ||
| 1318 | .unwrap(); | ||
| 1319 | let local = cache.snapshot().unwrap(); | ||
| 1320 | let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); | ||
| 1321 | let mut server = Server::new(&remote); | ||
| 1322 | assert_eq!( | ||
| 1323 | cache.sync_once(&mut server).unwrap(), | ||
| 1324 | Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) | ||
| 1325 | ); | ||
| 1326 | assert_eq!(server.publications, 0); | ||
| 1327 | drop(cache); | ||
| 1328 | let cache = Replica::open(&path).unwrap(); | ||
| 1329 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 1330 | assert_eq!(cache.remote_snapshot().unwrap(), remote); | ||
| 1331 | assert_eq!(cache.pending().unwrap().len(), 1); | ||
| 1332 | assert_eq!( | ||
| 1333 | cache.status(id).unwrap(), | ||
| 1334 | Some(EditStatus::Conflict(ConflictKind::TextChanged)) | ||
| 1335 | ); | ||
| 1336 | } | ||
| 1337 | |||
| 1338 | #[test] | ||
| 1339 | fn lost_replies_and_process_termination_never_blindly_replay_an_attempt() { | ||
| 1340 | for fault in [ | ||
| 1341 | Fault::UnknownBefore, | ||
| 1342 | Fault::UnknownAfter, | ||
| 1343 | Fault::PanicBefore, | ||
| 1344 | Fault::PanicAfter, | ||
| 1345 | ] { | ||
| 1346 | let dir = tempfile::tempdir().unwrap(); | ||
| 1347 | let path = dir.path().join("cache.sqlite"); | ||
| 1348 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1349 | let (sid, oid, _) = text(&source); | ||
| 1350 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1351 | let id = cache | ||
| 1352 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1353 | .unwrap() | ||
| 1354 | .unwrap(); | ||
| 1355 | let mut server = Server::new(&source); | ||
| 1356 | server.fault = fault; | ||
| 1357 | let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | ||
| 1358 | cache.sync_once(&mut server) | ||
| 1359 | })); | ||
| 1360 | let attempted = cache.status(id).unwrap().unwrap(); | ||
| 1361 | assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. })); | ||
| 1362 | drop(cache); | ||
| 1363 | let cache = Replica::open(&path).unwrap(); | ||
| 1364 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 1365 | if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) { | ||
| 1366 | assert!(matches!(result.1, EditStatus::Published { .. })); | ||
| 1367 | assert_eq!(text(&server.durable).2, "Once abc"); | ||
| 1368 | assert_eq!(server.confirmations, 1); | ||
| 1369 | assert!(cache.pending().unwrap().is_empty()); | ||
| 1370 | } else { | ||
| 1371 | assert_eq!(result.1, attempted); | ||
| 1372 | assert_eq!(cache.pending().unwrap().len(), 1); | ||
| 1373 | assert_eq!(server.confirmations, 0); | ||
| 1374 | assert_eq!(server.durable, source); | ||
| 1375 | } | ||
| 1376 | assert_eq!(server.publications, 1); | ||
| 1377 | } | ||
| 1378 | } | ||
| 1379 | |||
| 1380 | #[test] | ||
| 1381 | fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() { | ||
| 1382 | let dir = tempfile::tempdir().unwrap(); | ||
| 1383 | let path = dir.path().join("cache.sqlite"); | ||
| 1384 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1385 | let (sid, oid, _) = text(&source); | ||
| 1386 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1387 | let id = cache | ||
| 1388 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1389 | .unwrap() | ||
| 1390 | .unwrap(); | ||
| 1391 | let mut server = Server::new(&source); | ||
| 1392 | server.fault = Fault::UnknownAfter; | ||
| 1393 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 1394 | server.fault = Fault::Confirm; | ||
| 1395 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 1396 | assert_eq!(server.durable, source); | ||
| 1397 | assert!(matches!( | ||
| 1398 | cache.status(id).unwrap(), | ||
| 1399 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 1400 | )); | ||
| 1401 | assert!(matches!( | ||
| 1402 | cache.sync_once(&mut server).unwrap(), | ||
| 1403 | Some((_, EditStatus::Published { .. })) | ||
| 1404 | )); | ||
| 1405 | assert_eq!(server.publications, 1); | ||
| 1406 | assert_eq!(server.confirmations, 2); | ||
| 1407 | assert_eq!(server.visible, server.durable); | ||
| 1408 | } | ||
| 1409 | |||
| 1410 | #[test] | ||
| 1411 | fn confirmation_cleanup_failure_still_records_a_durable_receipt() { | ||
| 1412 | let dir = tempfile::tempdir().unwrap(); | ||
| 1413 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1414 | let (sid, oid, _) = text(&source); | ||
| 1415 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | ||
| 1416 | let id = cache | ||
| 1417 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1418 | .unwrap() | ||
| 1419 | .unwrap(); | ||
| 1420 | let mut server = Server::new(&source); | ||
| 1421 | server.fault = Fault::UnknownAfter; | ||
| 1422 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 1423 | server.fault = Fault::ConfirmCommitted; | ||
| 1424 | assert!( | ||
| 1425 | matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Committed) | ||
| 1426 | ); | ||
| 1427 | assert!(matches!( | ||
| 1428 | cache.status(id).unwrap(), | ||
| 1429 | Some(EditStatus::Published { .. }) | ||
| 1430 | )); | ||
| 1431 | assert!(cache.pending().unwrap().is_empty()); | ||
| 1432 | assert_eq!(server.visible, server.durable); | ||
| 1433 | assert_eq!(server.publications, 1); | ||
| 1434 | assert_eq!(server.confirmations, 1); | ||
| 1435 | } | ||
| 1436 | |||
| 1437 | #[test] | ||
| 1438 | fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts() { | ||
| 1439 | for fault in [Fault::Before, Fault::Committed] { | ||
| 1440 | let dir = tempfile::tempdir().unwrap(); | ||
| 1441 | let path = dir.path().join("cache.sqlite"); | ||
| 1442 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1443 | let (sid, oid, _) = text(&source); | ||
| 1444 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1445 | let id = cache | ||
| 1446 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1447 | .unwrap() | ||
| 1448 | .unwrap(); | ||
| 1449 | let mut server = Server::new(&source); | ||
| 1450 | server.fault = fault; | ||
| 1451 | assert!(matches!( | ||
| 1452 | cache.sync_once(&mut server), | ||
| 1453 | Err(Error::Remote(_)) | ||
| 1454 | )); | ||
| 1455 | if matches!(fault, Fault::Before) { | ||
| 1456 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 1457 | assert!(matches!( | ||
| 1458 | cache.sync_once(&mut server).unwrap(), | ||
| 1459 | Some((_, EditStatus::Published { .. })) | ||
| 1460 | )); | ||
| 1461 | assert_eq!(server.publications, 2); | ||
| 1462 | } else { | ||
| 1463 | assert!(matches!( | ||
| 1464 | cache.status(id).unwrap(), | ||
| 1465 | Some(EditStatus::Published { .. }) | ||
| 1466 | )); | ||
| 1467 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | ||
| 1468 | assert_eq!(server.publications, 1); | ||
| 1469 | } | ||
| 1470 | assert_eq!(text(&server.durable).2, "Once abc"); | ||
| 1471 | } | ||
| 1472 | } | ||
| 1473 | |||
| 1474 | #[test] | ||
| 1475 | fn database_failures_before_and_after_publication_preserve_recovery_state() { | ||
| 1476 | for table in ["attempt", "receipts"] { | ||
| 1477 | let dir = tempfile::tempdir().unwrap(); | ||
| 1478 | let path = dir.path().join("cache.sqlite"); | ||
| 1479 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1480 | let (sid, oid, _) = text(&source); | ||
| 1481 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1482 | let id = cache | ||
| 1483 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1484 | .unwrap() | ||
| 1485 | .unwrap(); | ||
| 1486 | drop(cache); | ||
| 1487 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 1488 | db.execute_batch(&format!("CREATE TRIGGER interrupted BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT,'Injected cache failure'); END;")).unwrap(); | ||
| 1489 | drop(db); | ||
| 1490 | let cache = Replica::open(&path).unwrap(); | ||
| 1491 | let mut server = Server::new(&source); | ||
| 1492 | assert!(matches!( | ||
| 1493 | cache.sync_once(&mut server), | ||
| 1494 | Err(Error::Database(_)) | ||
| 1495 | )); | ||
| 1496 | assert_eq!(server.publications, usize::from(table == "receipts")); | ||
| 1497 | assert_eq!(cache.pending().unwrap().len(), 1); | ||
| 1498 | drop(cache); | ||
| 1499 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 1500 | db.execute_batch("DROP TRIGGER interrupted").unwrap(); | ||
| 1501 | drop(db); | ||
| 1502 | let cache = Replica::open(&path).unwrap(); | ||
| 1503 | assert!(matches!( | ||
| 1504 | cache.sync_once(&mut server).unwrap(), | ||
| 1505 | Some((_, EditStatus::Published { .. })) | ||
| 1506 | )); | ||
| 1507 | assert!(matches!( | ||
| 1508 | cache.status(id).unwrap(), | ||
| 1509 | Some(EditStatus::Published { .. }) | ||
| 1510 | )); | ||
| 1511 | assert_eq!(server.publications, 1); | ||
| 1512 | assert_eq!(server.confirmations, usize::from(table == "receipts")); | ||
| 1513 | assert_eq!(text(&server.durable).2, "Once abc"); | ||
| 1514 | } | ||
| 1515 | } | ||
| 1516 | |||
| 1517 | #[test] | ||
| 1518 | fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmation() { | ||
| 1519 | struct Paused { | ||
| 1520 | server: Server, | ||
| 1521 | phase: &'static str, | ||
| 1522 | entered: std::sync::mpsc::Sender<()>, | ||
| 1523 | resume: std::sync::mpsc::Receiver<()>, | ||
| 1524 | } | ||
| 1525 | impl Paused { | ||
| 1526 | fn wait(&self, phase: &str) { | ||
| 1527 | if self.phase == phase { | ||
| 1528 | self.entered.send(()).unwrap(); | ||
| 1529 | self.resume | ||
| 1530 | .recv_timeout(std::time::Duration::from_secs(5)) | ||
| 1531 | .unwrap(); | ||
| 1532 | } | ||
| 1533 | } | ||
| 1534 | } | ||
| 1535 | impl Remote for Paused { | ||
| 1536 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 1537 | self.wait("read"); | ||
| 1538 | self.server.read() | ||
| 1539 | } | ||
| 1540 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 1541 | self.wait("publish"); | ||
| 1542 | self.server.publish(edit) | ||
| 1543 | } | ||
| 1544 | fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { | ||
| 1545 | self.wait("confirm"); | ||
| 1546 | self.server.confirm(source) | ||
| 1547 | } | ||
| 1548 | } | ||
| 1549 | for phase in ["read", "publish", "confirm"] { | ||
| 1550 | let dir = tempfile::tempdir().unwrap(); | ||
| 1551 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1552 | let (sid, oid, _) = text(&source); | ||
| 1553 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | ||
| 1554 | let first = cache | ||
| 1555 | .edit_text(&source, sid, oid, 0..0, "First ") | ||
| 1556 | .unwrap() | ||
| 1557 | .unwrap(); | ||
| 1558 | let mut server = Server::new(&source); | ||
| 1559 | if phase == "confirm" { | ||
| 1560 | server.fault = Fault::UnknownAfter; | ||
| 1561 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 1562 | } | ||
| 1563 | let (entered_tx, entered_rx) = std::sync::mpsc::channel(); | ||
| 1564 | let (resume_tx, resume_rx) = std::sync::mpsc::channel(); | ||
| 1565 | let mut paused = Paused { | ||
| 1566 | server, | ||
| 1567 | phase, | ||
| 1568 | entered: entered_tx, | ||
| 1569 | resume: resume_rx, | ||
| 1570 | }; | ||
| 1571 | let mut server = std::thread::scope(|scope| { | ||
| 1572 | let running = scope.spawn(|| { | ||
| 1573 | let result = cache.sync_once(&mut paused); | ||
| 1574 | assert!( | ||
| 1575 | matches!(result, Ok(Some((id, EditStatus::Published { .. }))) if id == first) | ||
| 1576 | ); | ||
| 1577 | paused.server | ||
| 1578 | }); | ||
| 1579 | entered_rx | ||
| 1580 | .recv_timeout(std::time::Duration::from_secs(5)) | ||
| 1581 | .unwrap(); | ||
| 1582 | assert!( | ||
| 1583 | matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock) | ||
| 1584 | ); | ||
| 1585 | assert!( | ||
| 1586 | matches!(cache.rebase_conflict(first, &[], &[], 0..0), Err(Error::Io(error)) if error.kind() == io::ErrorKind::WouldBlock) | ||
| 1587 | ); | ||
| 1588 | let started = std::time::Instant::now(); | ||
| 1589 | let handles: Vec<_> = (0..12) | ||
| 1590 | .map(|writer| { | ||
| 1591 | let cache = &cache; | ||
| 1592 | scope.spawn(move || { | ||
| 1593 | loop { | ||
| 1594 | let snapshot = cache.snapshot().unwrap(); | ||
| 1595 | match cache.edit_text( | ||
| 1596 | &snapshot, | ||
| 1597 | sid, | ||
| 1598 | oid, | ||
| 1599 | 0..0, | ||
| 1600 | &format!("[{writer}] "), | ||
| 1601 | ) { | ||
| 1602 | Ok(Some(id)) => break id, | ||
| 1603 | Err(Error::Io(error)) | ||
| 1604 | if error.kind() == io::ErrorKind::ResourceBusy => {} | ||
| 1605 | other => panic!("Unexpected local outcome {other:?}"), | ||
| 1606 | } | ||
| 1607 | } | ||
| 1608 | }) | ||
| 1609 | }) | ||
| 1610 | .collect(); | ||
| 1611 | let ids: std::collections::BTreeSet<_> = handles | ||
| 1612 | .into_iter() | ||
| 1613 | .map(|handle| handle.join().unwrap()) | ||
| 1614 | .collect(); | ||
| 1615 | assert_eq!(ids.len(), 12); | ||
| 1616 | assert!( | ||
| 1617 | started.elapsed() < std::time::Duration::from_secs(2), | ||
| 1618 | "Local edits waited for remote {phase}" | ||
| 1619 | ); | ||
| 1620 | resume_tx.send(()).unwrap(); | ||
| 1621 | running.join().unwrap() | ||
| 1622 | }); | ||
| 1623 | assert_eq!(cache.pending().unwrap().len(), 12); | ||
| 1624 | let expected = text(&cache.snapshot().unwrap()).2; | ||
| 1625 | for _ in 0..12 { | ||
| 1626 | assert!(matches!( | ||
| 1627 | cache.sync_once(&mut server).unwrap(), | ||
| 1628 | Some((_, EditStatus::Published { .. })) | ||
| 1629 | )); | ||
| 1630 | } | ||
| 1631 | assert!(cache.pending().unwrap().is_empty()); | ||
| 1632 | assert_eq!(server.publications, 13); | ||
| 1633 | assert_eq!(text(&server.durable).2, expected); | ||
| 1634 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 1635 | } | ||
| 1636 | } | ||
| 1637 | |||
| 1638 | #[test] | ||
| 1639 | fn unrelated_remote_files_never_replace_a_local_cache() { | ||
| 1640 | let dir = tempfile::tempdir().unwrap(); | ||
| 1641 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1642 | let (sid, oid, _) = text(&source); | ||
| 1643 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | ||
| 1644 | let other = onestore::create_section("other.one", "abc", "Fixture").unwrap(); | ||
| 1645 | let mut server = Server::new(&other); | ||
| 1646 | for pending in [false, true] { | ||
| 1647 | if pending { | ||
| 1648 | cache.edit_text(&source, sid, oid, 0..0, "Local ").unwrap(); | ||
| 1649 | } | ||
| 1650 | let before = cache.snapshot().unwrap(); | ||
| 1651 | assert!( | ||
| 1652 | matches!(cache.sync_once(&mut server),Err(Error::Io(error)) if error.kind()==io::ErrorKind::InvalidInput) | ||
| 1653 | ); | ||
| 1654 | assert_eq!(cache.snapshot().unwrap(), before); | ||
| 1655 | assert_eq!(cache.remote_snapshot().unwrap(), source); | ||
| 1656 | assert_eq!(server.publications, 0); | ||
| 1657 | } | ||
| 1658 | } | ||
| 1659 | |||
| 1660 | #[test] | ||
| 1661 | fn version_one_cache_migration_preserves_images_intents_and_local_ids() { | ||
| 1662 | let dir = tempfile::tempdir().unwrap(); | ||
| 1663 | let path = dir.path().join("cache.sqlite"); | ||
| 1664 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | ||
| 1665 | let (sid, oid, _) = text(&source); | ||
| 1666 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1667 | let id = cache | ||
| 1668 | .edit_text(&source, sid, oid, 0..0, "Local ") | ||
| 1669 | .unwrap() | ||
| 1670 | .unwrap(); | ||
| 1671 | let snapshot = cache.snapshot().unwrap(); | ||
| 1672 | let pending = cache.pending().unwrap(); | ||
| 1673 | drop(cache); | ||
| 1674 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 1675 | let current_version: u32 = db | ||
| 1676 | .pragma_query_value(None, "user_version", |row| row.get(0)) | ||
| 1677 | .unwrap(); | ||
| 1678 | db.execute_batch( | ||
| 1679 | "DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts; DROP TABLE edits; DROP TABLE assets; | ||
| 1680 | CREATE TABLE edits ( | ||
| 1681 | id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), space TEXT NOT NULL, | ||
| 1682 | object TEXT NOT NULL, before_text TEXT NOT NULL, | ||
| 1683 | start INTEGER NOT NULL CHECK(start BETWEEN 0 AND 4294967295), | ||
| 1684 | end INTEGER NOT NULL CHECK(end BETWEEN start AND 4294967295), replacement TEXT NOT NULL | ||
| 1685 | ) STRICT; PRAGMA user_version=1;", | ||
| 1686 | ) | ||
| 1687 | .unwrap(); | ||
| 1688 | db.execute("INSERT INTO edits(id,space,object,before_text,start,end,replacement) VALUES (?1,?2,?3,'abc',0,0,'Local ')",rusqlite::params![i64::try_from(id).unwrap(),sid.to_string(),oid.to_string()]).unwrap(); | ||
| 1689 | drop(db); | ||
| 1690 | let cache = Replica::open(&path).unwrap(); | ||
| 1691 | assert_eq!(cache.snapshot().unwrap(), snapshot); | ||
| 1692 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 1693 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 1694 | let mut server = Server::new(&source); | ||
| 1695 | assert!(matches!( | ||
| 1696 | cache.sync_once(&mut server).unwrap(), | ||
| 1697 | Some((_, EditStatus::Published { .. })) | ||
| 1698 | )); | ||
| 1699 | drop(cache); | ||
| 1700 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 1701 | assert_eq!( | ||
| 1702 | db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) | ||
| 1703 | .unwrap(), | ||
| 1704 | current_version | ||
| 1705 | ); | ||
| 1706 | } | ||
| 1707 | |||
| 1708 | #[test] | ||
| 1709 | fn reviewed_conflict_rebase_preserves_twelve_dependent_edits_and_survives_reopen() { | ||
| 1710 | let dir = tempfile::tempdir().unwrap(); | ||
| 1711 | let path = dir.path().join("cache.sqlite"); | ||
| 1712 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | ||
| 1713 | let (sid, oid, _) = text(&source); | ||
| 1714 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1715 | let first = cache | ||
| 1716 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 1717 | .unwrap() | ||
| 1718 | .unwrap(); | ||
| 1719 | for n in 0..12 { | ||
| 1720 | cache | ||
| 1721 | .edit_text( | ||
| 1722 | &cache.snapshot().unwrap(), | ||
| 1723 | sid, | ||
| 1724 | oid, | ||
| 1725 | 0..0, | ||
| 1726 | &format!("[{n}] "), | ||
| 1727 | ) | ||
| 1728 | .unwrap(); | ||
| 1729 | } | ||
| 1730 | let local = cache.snapshot().unwrap(); | ||
| 1731 | let pending = cache.pending().unwrap(); | ||
| 1732 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "aRcZ").unwrap(); | ||
| 1733 | let mut server = Server::new(&remote); | ||
| 1734 | assert_eq!( | ||
| 1735 | cache.sync_once(&mut server).unwrap(), | ||
| 1736 | Some((first, EditStatus::Conflict(ConflictKind::TextChanged))) | ||
| 1737 | ); | ||
| 1738 | cache.rebase_conflict(first, &local, &remote, 1..2).unwrap(); | ||
| 1739 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 1740 | assert_eq!(cache.remote_snapshot().unwrap(), remote); | ||
| 1741 | let rebased = cache.pending().unwrap(); | ||
| 1742 | assert_eq!(&rebased[1..], &pending[1..]); | ||
| 1743 | assert_eq!(rebased[0].id, first); | ||
| 1744 | let onestore_offline::Operation::Text(updated) = &rebased[0].operation else { | ||
| 1745 | panic!() | ||
| 1746 | }; | ||
| 1747 | let onestore_offline::Operation::Text(previous) = &pending[0].operation else { | ||
| 1748 | panic!() | ||
| 1749 | }; | ||
| 1750 | assert_eq!(updated.replacement, previous.replacement); | ||
| 1751 | assert_eq!(updated.before, "aRcZ"); | ||
| 1752 | assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending)); | ||
| 1753 | drop(cache); | ||
| 1754 | let cache = Replica::open(&path).unwrap(); | ||
| 1755 | assert_eq!(cache.pending().unwrap(), rebased); | ||
| 1756 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 1757 | for intent in &pending { | ||
| 1758 | assert!( | ||
| 1759 | matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == intent.id) | ||
| 1760 | ); | ||
| 1761 | } | ||
| 1762 | assert_eq!(server.publications, 13); | ||
| 1763 | assert_eq!(text(&server.durable).2, text(&local).2 + "Z"); | ||
| 1764 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 1765 | assert!(cache.pending().unwrap().is_empty()); | ||
| 1766 | } | ||
| 1767 | |||
| 1768 | #[test] | ||
| 1769 | fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states() { | ||
| 1770 | let dir = tempfile::tempdir().unwrap(); | ||
| 1771 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | ||
| 1772 | let (sid, oid, _) = text(&source); | ||
| 1773 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | ||
| 1774 | let id = cache | ||
| 1775 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 1776 | .unwrap() | ||
| 1777 | .unwrap(); | ||
| 1778 | let local = cache.snapshot().unwrap(); | ||
| 1779 | assert!( | ||
| 1780 | matches!(cache.rebase_conflict(id, &local, &source, 1..2), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) | ||
| 1781 | ); | ||
| 1782 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "🦀Rc").unwrap(); | ||
| 1783 | let mut server = Server::new(&remote); | ||
| 1784 | assert!(matches!( | ||
| 1785 | cache.sync_once(&mut server).unwrap(), | ||
| 1786 | Some((_, EditStatus::Conflict(_))) | ||
| 1787 | )); | ||
| 1788 | let pending = cache.pending().unwrap(); | ||
| 1789 | for range in [1..2, 100..101] { | ||
| 1790 | assert!(matches!( | ||
| 1791 | cache.rebase_conflict(id, &local, &remote, range), | ||
| 1792 | Err(Error::Document(_)) | ||
| 1793 | )); | ||
| 1794 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 1795 | } | ||
| 1796 | assert!( | ||
| 1797 | matches!(cache.rebase_conflict(id + 1, &local, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) | ||
| 1798 | ); | ||
| 1799 | cache.edit_text(&local, sid, oid, 0..0, "Later ").unwrap(); | ||
| 1800 | let changed = cache.snapshot().unwrap(); | ||
| 1801 | assert!( | ||
| 1802 | matches!(cache.rebase_conflict(id, &local, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy) | ||
| 1803 | ); | ||
| 1804 | let new_remote = onestore::replace_text(&remote, sid, oid, 2..3, "Q").unwrap(); | ||
| 1805 | server.visible = new_remote.clone(); | ||
| 1806 | server.durable = new_remote; | ||
| 1807 | assert!(matches!( | ||
| 1808 | cache.sync_once(&mut server).unwrap(), | ||
| 1809 | Some((_, EditStatus::Conflict(_))) | ||
| 1810 | )); | ||
| 1811 | assert!( | ||
| 1812 | matches!(cache.rebase_conflict(id, &changed, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy) | ||
| 1813 | ); | ||
| 1814 | assert_eq!(cache.snapshot().unwrap(), changed); | ||
| 1815 | assert_eq!(cache.pending().unwrap()[0], pending[0]); | ||
| 1816 | assert_eq!(server.publications, 0); | ||
| 1817 | |||
| 1818 | let current = cache.remote_snapshot().unwrap(); | ||
| 1819 | cache.rebase_conflict(id, &changed, &current, 2..3).unwrap(); | ||
| 1820 | server.fault = Fault::UnknownBefore; | ||
| 1821 | assert!( | ||
| 1822 | matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Unknown) | ||
| 1823 | ); | ||
| 1824 | let attempted = cache.status(id).unwrap(); | ||
| 1825 | let pending = cache.pending().unwrap(); | ||
| 1826 | assert!( | ||
| 1827 | matches!(cache.rebase_conflict(id, &changed, &current, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) | ||
| 1828 | ); | ||
| 1829 | assert_eq!(cache.status(id).unwrap(), attempted); | ||
| 1830 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 1831 | assert_eq!(server.publications, 1); | ||
| 1832 | } | ||
| 1833 | |||
| 1834 | #[test] | ||
| 1835 | fn failure_between_rebase_and_conflict_clear_rolls_back_the_entire_resolution() { | ||
| 1836 | let dir = tempfile::tempdir().unwrap(); | ||
| 1837 | let path = dir.path().join("cache.sqlite"); | ||
| 1838 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | ||
| 1839 | let (sid, oid, _) = text(&source); | ||
| 1840 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 1841 | let id = cache | ||
| 1842 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 1843 | .unwrap() | ||
| 1844 | .unwrap(); | ||
| 1845 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "XaRc").unwrap(); | ||
| 1846 | let mut server = Server::new(&remote); | ||
| 1847 | cache.sync_once(&mut server).unwrap(); | ||
| 1848 | let local = cache.snapshot().unwrap(); | ||
| 1849 | let pending = cache.pending().unwrap(); | ||
| 1850 | let status = cache.status(id).unwrap(); | ||
| 1851 | drop(cache); | ||
| 1852 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 1853 | connection.execute_batch("CREATE TRIGGER fail_clear BEFORE DELETE ON conflicts BEGIN SELECT RAISE(ABORT, 'test conflict clear failure'); END;").unwrap(); | ||
| 1854 | drop(connection); | ||
| 1855 | let cache = Replica::open(&path).unwrap(); | ||
| 1856 | assert!(matches!( | ||
| 1857 | cache.rebase_conflict(id, &local, &remote, 2..3), | ||
| 1858 | Err(Error::Database(_)) | ||
| 1859 | )); | ||
| 1860 | drop(cache); | ||
| 1861 | let cache = Replica::open(&path).unwrap(); | ||
| 1862 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 1863 | assert_eq!(cache.status(id).unwrap(), status); | ||
| 1864 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 1865 | assert_eq!(cache.remote_snapshot().unwrap(), remote); | ||
| 1866 | assert_eq!(server.publications, 0); | ||
| 1867 | } | ||
| 1868 | |||
| 1869 | #[test] | ||
| 1870 | fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replacement() { | ||
| 1871 | let dir = tempfile::tempdir().unwrap(); | ||
| 1872 | let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect(); | ||
| 1873 | let mut seed = 911_u64; | ||
| 1874 | for case in 0..64 { | ||
| 1875 | seed ^= seed << 13; | ||
| 1876 | seed ^= seed >> 7; | ||
| 1877 | seed ^= seed << 17; | ||
| 1878 | let at = seed as usize % original.len(); | ||
| 1879 | let prefix = "[".repeat((seed >> 8) as usize % 5); | ||
| 1880 | let suffix = "]".repeat((seed >> 16) as usize % 5); | ||
| 1881 | let start: u32 = original[..at].iter().map(|ch| ch.len_utf16() as u32).sum(); | ||
| 1882 | let end = start + original[at].len_utf16() as u32; | ||
| 1883 | let source = onestore::create_section( | ||
| 1884 | "resolve.one", | ||
| 1885 | &original.iter().collect::<String>(), | ||
| 1886 | "Fixture", | ||
| 1887 | ) | ||
| 1888 | .unwrap(); | ||
| 1889 | let (sid, oid, _) = text(&source); | ||
| 1890 | let cache = Replica::create(dir.path().join(format!("{case}.sqlite")), &source).unwrap(); | ||
| 1891 | let id = cache | ||
| 1892 | .edit_text(&source, sid, oid, start..end, "λ🦊μ") | ||
| 1893 | .unwrap() | ||
| 1894 | .unwrap(); | ||
| 1895 | let dependent = cache | ||
| 1896 | .edit_text( | ||
| 1897 | &cache.snapshot().unwrap(), | ||
| 1898 | sid, | ||
| 1899 | oid, | ||
| 1900 | start + 1..start + 3, | ||
| 1901 | "🐕", | ||
| 1902 | ) | ||
| 1903 | .unwrap() | ||
| 1904 | .unwrap(); | ||
| 1905 | let mut remote_text = original.clone(); | ||
| 1906 | remote_text.splice(at..at + 1, "Ω🐈π".chars()); | ||
| 1907 | let remote_text = prefix.clone() + &remote_text.iter().collect::<String>() + &suffix; | ||
| 1908 | let remote = onestore::replace_text( | ||
| 1909 | &source, | ||
| 1910 | sid, | ||
| 1911 | oid, | ||
| 1912 | 0..original.iter().map(|ch| ch.len_utf16() as u32).sum(), | ||
| 1913 | &remote_text, | ||
| 1914 | ) | ||
| 1915 | .unwrap(); | ||
| 1916 | let mut server = Server::new(&remote); | ||
| 1917 | assert!( | ||
| 1918 | matches!( | ||
| 1919 | cache.sync_once(&mut server).unwrap(), | ||
| 1920 | Some((_, EditStatus::Conflict(_))) | ||
| 1921 | ), | ||
| 1922 | "case {case}, seed {seed}" | ||
| 1923 | ); | ||
| 1924 | let at_remote = start + prefix.len() as u32; | ||
| 1925 | cache | ||
| 1926 | .rebase_conflict( | ||
| 1927 | id, | ||
| 1928 | &cache.snapshot().unwrap(), | ||
| 1929 | &remote, | ||
| 1930 | at_remote..at_remote + 4, | ||
| 1931 | ) | ||
| 1932 | .unwrap(); | ||
| 1933 | for expected in [id, dependent] { | ||
| 1934 | assert!( | ||
| 1935 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected), | ||
| 1936 | "case {case}, seed {seed}" | ||
| 1937 | ); | ||
| 1938 | } | ||
| 1939 | let mut expected = original.clone(); | ||
| 1940 | expected.splice(at..at + 1, "λ🐕μ".chars()); | ||
| 1941 | let expected = prefix + &expected.iter().collect::<String>() + &suffix; | ||
| 1942 | assert_eq!( | ||
| 1943 | text(&server.durable).2, | ||
| 1944 | expected, | ||
| 1945 | "case {case}, seed {seed}" | ||
| 1946 | ); | ||
| 1947 | assert_eq!(server.publications, 2); | ||
| 1948 | assert!(cache.pending().unwrap().is_empty()); | ||
| 1949 | } | ||
| 1950 | } | ||
| 1951 | |||
| 1952 | #[test] | ||
| 1953 | fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() { | ||
| 1954 | struct ChangedAfterRead { | ||
| 1955 | server: Server, | ||
| 1956 | change: Option<Vec<u8>>, | ||
| 1957 | } | ||
| 1958 | impl Remote for ChangedAfterRead { | ||
| 1959 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 1960 | let snapshot = self.server.read()?; | ||
| 1961 | if let Some(changed) = self.change.take() { | ||
| 1962 | self.server.visible = changed.clone(); | ||
| 1963 | self.server.durable = changed; | ||
| 1964 | } | ||
| 1965 | Ok(snapshot) | ||
| 1966 | } | ||
| 1967 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 1968 | self.server.publish(edit) | ||
| 1969 | } | ||
| 1970 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 1971 | self.server.confirm(snapshot) | ||
| 1972 | } | ||
| 1973 | } | ||
| 1974 | for after_read in [false, true] { | ||
| 1975 | let dir = tempfile::tempdir().unwrap(); | ||
| 1976 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | ||
| 1977 | let (sid, oid, _) = text(&source); | ||
| 1978 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | ||
| 1979 | let id = cache | ||
| 1980 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 1981 | .unwrap() | ||
| 1982 | .unwrap(); | ||
| 1983 | let local = cache.snapshot().unwrap(); | ||
| 1984 | let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); | ||
| 1985 | let mut remote = ChangedAfterRead { | ||
| 1986 | server: Server::new(&remote), | ||
| 1987 | change: None, | ||
| 1988 | }; | ||
| 1989 | assert!(matches!( | ||
| 1990 | cache.sync_once(&mut remote).unwrap(), | ||
| 1991 | Some((_, EditStatus::Conflict(_))) | ||
| 1992 | )); | ||
| 1993 | cache | ||
| 1994 | .rebase_conflict(id, &local, &cache.remote_snapshot().unwrap(), 1..2) | ||
| 1995 | .unwrap(); | ||
| 1996 | let changed = onestore::replace_text(&remote.server.visible, sid, oid, 1..2, "Q").unwrap(); | ||
| 1997 | if after_read { | ||
| 1998 | remote.change = Some(changed.clone()); | ||
| 1999 | } else { | ||
| 2000 | remote.server.visible = changed.clone(); | ||
| 2001 | remote.server.durable = changed.clone(); | ||
| 2002 | } | ||
| 2003 | if after_read { | ||
| 2004 | assert!( | ||
| 2005 | matches!(cache.sync_once(&mut remote), Err(Error::Remote(error)) if error.state == CommitState::NotCommitted) | ||
| 2006 | ); | ||
| 2007 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 2008 | } | ||
| 2009 | assert_eq!( | ||
| 2010 | cache.sync_once(&mut remote).unwrap(), | ||
| 2011 | Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) | ||
| 2012 | ); | ||
| 2013 | assert_eq!(remote.server.durable, changed); | ||
| 2014 | assert_eq!(remote.server.visible, changed); | ||
| 2015 | assert_eq!(remote.server.publications, usize::from(after_read)); | ||
| 2016 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 2017 | let onestore_offline::Operation::Text(edit) = &cache.pending().unwrap()[0].operation else { | ||
| 2018 | panic!() | ||
| 2019 | }; | ||
| 2020 | assert_eq!(edit.replacement, "L"); | ||
| 2021 | } | ||
| 2022 | } | ||
| 2023 | |||
| 2024 | mod worker { | ||
| 2025 | use super::*; | ||
| 2026 | use std::{ | ||
| 2027 | sync::{Arc, Mutex, mpsc}, | ||
| 2028 | time::{Duration, Instant}, | ||
| 2029 | }; | ||
| 2030 | |||
| 2031 | #[derive(Clone)] | ||
| 2032 | struct Shared(Arc<Mutex<Server>>); | ||
| 2033 | |||
| 2034 | impl Remote for Shared { | ||
| 2035 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 2036 | self.0.lock().unwrap().read() | ||
| 2037 | } | ||
| 2038 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 2039 | self.0.lock().unwrap().publish(edit) | ||
| 2040 | } | ||
| 2041 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 2042 | self.0.lock().unwrap().confirm(snapshot) | ||
| 2043 | } | ||
| 2044 | } | ||
| 2045 | |||
| 2046 | #[test] | ||
| 2047 | fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() { | ||
| 2048 | struct Session { | ||
| 2049 | shared: Shared, | ||
| 2050 | fail_read: bool, | ||
| 2051 | } | ||
| 2052 | impl Remote for Session { | ||
| 2053 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 2054 | if self.fail_read { | ||
| 2055 | return Err(io::ErrorKind::ConnectionReset.into()); | ||
| 2056 | } | ||
| 2057 | self.shared.read() | ||
| 2058 | } | ||
| 2059 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 2060 | self.shared.publish(edit) | ||
| 2061 | } | ||
| 2062 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 2063 | self.shared.confirm(snapshot) | ||
| 2064 | } | ||
| 2065 | } | ||
| 2066 | let dir = tempfile::tempdir().unwrap(); | ||
| 2067 | let path = dir.path().join("cache.sqlite"); | ||
| 2068 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2069 | let (sid, oid, _) = text(&source); | ||
| 2070 | let cache = Arc::new(Replica::create(&path, &source).unwrap()); | ||
| 2071 | let id = cache | ||
| 2072 | .edit_text(&source, sid, oid, 1..2, "🦀") | ||
| 2073 | .unwrap() | ||
| 2074 | .unwrap(); | ||
| 2075 | let mut server = Server::new(&source); | ||
| 2076 | server.fault = Fault::UnknownAfter; | ||
| 2077 | let server = Arc::new(Mutex::new(server)); | ||
| 2078 | let shared = Shared(Arc::clone(&server)); | ||
| 2079 | let (connected_tx, connected_rx) = mpsc::channel(); | ||
| 2080 | let (observed_tx, observed_rx) = mpsc::channel(); | ||
| 2081 | let mut connections = 0; | ||
| 2082 | let worker = cache | ||
| 2083 | .start_sync( | ||
| 2084 | Duration::from_millis(10), | ||
| 2085 | move || { | ||
| 2086 | connections += 1; | ||
| 2087 | connected_tx.send(connections).unwrap(); | ||
| 2088 | if connections <= 2 { | ||
| 2089 | return Err(io::ErrorKind::ConnectionRefused.into()); | ||
| 2090 | } | ||
| 2091 | Ok(Session { | ||
| 2092 | shared: shared.clone(), | ||
| 2093 | fail_read: connections == 3, | ||
| 2094 | }) | ||
| 2095 | }, | ||
| 2096 | move |result| { | ||
| 2097 | observed_tx | ||
| 2098 | .send(result.as_ref().copied().map_err(|error| error.to_string())) | ||
| 2099 | .unwrap(); | ||
| 2100 | }, | ||
| 2101 | ) | ||
| 2102 | .unwrap(); | ||
| 2103 | let mut errors = 0; | ||
| 2104 | let published = loop { | ||
| 2105 | match observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() { | ||
| 2106 | Err(_) => errors += 1, | ||
| 2107 | Ok(Some((actual, status @ EditStatus::Published { .. }))) => { | ||
| 2108 | assert_eq!(actual, id); | ||
| 2109 | break status; | ||
| 2110 | } | ||
| 2111 | other => panic!("Unexpected result: {other:?}"), | ||
| 2112 | } | ||
| 2113 | }; | ||
| 2114 | worker.stop().unwrap(); | ||
| 2115 | assert_eq!(errors, 4); | ||
| 2116 | assert_eq!(connected_rx.try_iter().collect::<Vec<_>>(), [1, 2, 3, 4, 5]); | ||
| 2117 | let server = server.lock().unwrap(); | ||
| 2118 | assert_eq!(server.publications, 1); | ||
| 2119 | assert_eq!(server.confirmations, 1); | ||
| 2120 | assert_eq!(text(&server.durable).2, "a🦀c"); | ||
| 2121 | drop(cache); | ||
| 2122 | let cache = Replica::open(&path).unwrap(); | ||
| 2123 | assert_eq!(cache.status(id).unwrap(), Some(published)); | ||
| 2124 | let cached = cache.snapshot().unwrap(); | ||
| 2125 | // Confirmation changes reader-notification fields without changing the committed graph. | ||
| 2126 | assert_eq!(cached[..212], server.durable[..212]); | ||
| 2127 | assert_eq!(cached[252..], server.durable[252..]); | ||
| 2128 | let cached_generation = Store::parse(&cached).unwrap().header.generation; | ||
| 2129 | let remote_generation = Store::parse(&server.durable).unwrap().header.generation; | ||
| 2130 | if cached_generation == remote_generation { | ||
| 2131 | assert_eq!(cached, server.durable); | ||
| 2132 | } else { | ||
| 2133 | assert_eq!(cached_generation.checked_add(1), Some(remote_generation)); | ||
| 2134 | } | ||
| 2135 | } | ||
| 2136 | |||
| 2137 | #[test] | ||
| 2138 | fn local_edits_wake_an_idle_worker_and_coalesced_notifications_drain_twelve_writers() { | ||
| 2139 | let dir = tempfile::tempdir().unwrap(); | ||
| 2140 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2141 | let (sid, oid, _) = text(&source); | ||
| 2142 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2143 | let server = Arc::new(Mutex::new(Server::new(&source))); | ||
| 2144 | let shared = Shared(Arc::clone(&server)); | ||
| 2145 | let (observed_tx, observed_rx) = mpsc::channel(); | ||
| 2146 | let (resume_tx, resume_rx) = mpsc::channel(); | ||
| 2147 | let mut first = true; | ||
| 2148 | let worker = cache | ||
| 2149 | .start_sync( | ||
| 2150 | Duration::from_secs(3600), | ||
| 2151 | move || Ok(shared.clone()), | ||
| 2152 | move |result| { | ||
| 2153 | observed_tx.send(*result.as_ref().unwrap()).unwrap(); | ||
| 2154 | if first { | ||
| 2155 | first = false; | ||
| 2156 | resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2157 | } | ||
| 2158 | }, | ||
| 2159 | ) | ||
| 2160 | .unwrap(); | ||
| 2161 | assert_eq!( | ||
| 2162 | observed_rx.recv_timeout(Duration::from_secs(5)).unwrap(), | ||
| 2163 | None | ||
| 2164 | ); | ||
| 2165 | let started = Instant::now(); | ||
| 2166 | let edits = std::thread::scope(|scope| { | ||
| 2167 | (0..12) | ||
| 2168 | .map(|writer| { | ||
| 2169 | let cache = &cache; | ||
| 2170 | scope.spawn(move || { | ||
| 2171 | let replacement = format!("[{writer}] "); | ||
| 2172 | loop { | ||
| 2173 | let snapshot = cache.snapshot().unwrap(); | ||
| 2174 | match cache.edit_text(&snapshot, sid, oid, 0..0, &replacement) { | ||
| 2175 | Ok(Some(id)) => break (id, replacement), | ||
| 2176 | Err(Error::Io(error)) | ||
| 2177 | if error.kind() == io::ErrorKind::ResourceBusy => {} | ||
| 2178 | other => panic!("Unexpected local outcome: {other:?}"), | ||
| 2179 | } | ||
| 2180 | } | ||
| 2181 | }) | ||
| 2182 | }) | ||
| 2183 | .collect::<Vec<_>>() | ||
| 2184 | .into_iter() | ||
| 2185 | .map(|join| join.join().unwrap()) | ||
| 2186 | .collect::<std::collections::BTreeMap<_, _>>() | ||
| 2187 | }); | ||
| 2188 | resume_tx.send(()).unwrap(); | ||
| 2189 | let mut published = std::collections::BTreeSet::new(); | ||
| 2190 | while published.len() < 12 { | ||
| 2191 | if let Some((id, status)) = observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() { | ||
| 2192 | assert!(matches!(status, EditStatus::Published { .. }), "{status:?}"); | ||
| 2193 | assert!(published.insert(id)); | ||
| 2194 | } | ||
| 2195 | } | ||
| 2196 | assert!( | ||
| 2197 | started.elapsed() < Duration::from_secs(5), | ||
| 2198 | "Edits waited for the hourly poll" | ||
| 2199 | ); | ||
| 2200 | worker.stop().unwrap(); | ||
| 2201 | assert_eq!(published, edits.keys().copied().collect()); | ||
| 2202 | let expected = edits.values().rev().cloned().collect::<String>() + "abc"; | ||
| 2203 | let server = server.lock().unwrap(); | ||
| 2204 | assert_eq!(text(&server.durable).2, expected); | ||
| 2205 | assert_eq!(server.publications, 12); | ||
| 2206 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 2207 | assert!(cache.pending().unwrap().is_empty()); | ||
| 2208 | } | ||
| 2209 | |||
| 2210 | #[test] | ||
| 2211 | fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recovery_is_recorded() | ||
| 2212 | { | ||
| 2213 | struct Paused { | ||
| 2214 | shared: Shared, | ||
| 2215 | entered: mpsc::Sender<()>, | ||
| 2216 | resume: mpsc::Receiver<()>, | ||
| 2217 | } | ||
| 2218 | impl Remote for Paused { | ||
| 2219 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 2220 | self.shared.read() | ||
| 2221 | } | ||
| 2222 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 2223 | self.entered.send(()).unwrap(); | ||
| 2224 | self.resume.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2225 | self.shared.publish(edit) | ||
| 2226 | } | ||
| 2227 | fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { | ||
| 2228 | self.shared.confirm(snapshot) | ||
| 2229 | } | ||
| 2230 | } | ||
| 2231 | let dir = tempfile::tempdir().unwrap(); | ||
| 2232 | let path = dir.path().join("cache.sqlite"); | ||
| 2233 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2234 | let (sid, oid, _) = text(&source); | ||
| 2235 | let cache = Arc::new(Replica::create(&path, &source).unwrap()); | ||
| 2236 | let id = cache | ||
| 2237 | .edit_text(&source, sid, oid, 0..0, "L ") | ||
| 2238 | .unwrap() | ||
| 2239 | .unwrap(); | ||
| 2240 | let mut server = Server::new(&source); | ||
| 2241 | server.fault = Fault::UnknownAfter; | ||
| 2242 | let server = Arc::new(Mutex::new(server)); | ||
| 2243 | let shared = Shared(Arc::clone(&server)); | ||
| 2244 | let (entered_tx, entered_rx) = mpsc::channel(); | ||
| 2245 | let (resume_tx, resume_rx) = mpsc::channel(); | ||
| 2246 | let mut session = Some(Paused { | ||
| 2247 | shared, | ||
| 2248 | entered: entered_tx, | ||
| 2249 | resume: resume_rx, | ||
| 2250 | }); | ||
| 2251 | let worker = cache | ||
| 2252 | .start_sync( | ||
| 2253 | Duration::from_secs(3600), | ||
| 2254 | move || Ok(session.take().unwrap()), | ||
| 2255 | |_| {}, | ||
| 2256 | ) | ||
| 2257 | .unwrap(); | ||
| 2258 | entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2259 | let stopped = Instant::now(); | ||
| 2260 | drop(worker); | ||
| 2261 | assert!(stopped.elapsed() < Duration::from_millis(500)); | ||
| 2262 | let shared = Shared(Arc::clone(&server)); | ||
| 2263 | let start = cache.start_sync( | ||
| 2264 | Duration::from_secs(3600), | ||
| 2265 | move || Ok(shared.clone()), | ||
| 2266 | |_| {}, | ||
| 2267 | ); | ||
| 2268 | assert!(matches!(start, Err(error) if error.kind() == io::ErrorKind::WouldBlock)); | ||
| 2269 | let weak = Arc::downgrade(&cache); | ||
| 2270 | drop(cache); | ||
| 2271 | resume_tx.send(()).unwrap(); | ||
| 2272 | while weak.upgrade().is_some() { | ||
| 2273 | assert!(stopped.elapsed() < Duration::from_secs(5)); | ||
| 2274 | std::thread::sleep(Duration::from_millis(1)); | ||
| 2275 | } | ||
| 2276 | let cache = Arc::new(Replica::open(&path).unwrap()); | ||
| 2277 | assert!(matches!( | ||
| 2278 | cache.status(id).unwrap(), | ||
| 2279 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 2280 | )); | ||
| 2281 | assert_eq!(server.lock().unwrap().publications, 1); | ||
| 2282 | let shared = Shared(Arc::clone(&server)); | ||
| 2283 | let (tx, rx) = mpsc::channel(); | ||
| 2284 | let worker = cache | ||
| 2285 | .start_sync( | ||
| 2286 | Duration::from_secs(3600), | ||
| 2287 | move || Ok(shared.clone()), | ||
| 2288 | move |result| { | ||
| 2289 | tx.send(*result.as_ref().unwrap()).unwrap(); | ||
| 2290 | }, | ||
| 2291 | ) | ||
| 2292 | .unwrap(); | ||
| 2293 | assert!( | ||
| 2294 | matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | ||
| 2295 | ); | ||
| 2296 | worker.stop().unwrap(); | ||
| 2297 | let server = server.lock().unwrap(); | ||
| 2298 | assert_eq!(server.publications, 1); | ||
| 2299 | assert_eq!(server.confirmations, 1); | ||
| 2300 | assert_eq!(text(&server.durable).2, "L abc"); | ||
| 2301 | } | ||
| 2302 | |||
| 2303 | #[test] | ||
| 2304 | fn cache_failures_stop_retries_and_return_the_error_without_remote_publication() { | ||
| 2305 | let dir = tempfile::tempdir().unwrap(); | ||
| 2306 | let path = dir.path().join("cache.sqlite"); | ||
| 2307 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2308 | let (sid, oid, _) = text(&source); | ||
| 2309 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 2310 | let id = cache | ||
| 2311 | .edit_text(&source, sid, oid, 0..0, "L ") | ||
| 2312 | .unwrap() | ||
| 2313 | .unwrap(); | ||
| 2314 | drop(cache); | ||
| 2315 | let connection = rusqlite::Connection::open(&path).unwrap(); | ||
| 2316 | connection.execute_batch("CREATE TRIGGER fail_attempt BEFORE INSERT ON attempt BEGIN SELECT RAISE(ABORT, 'test cache write failure'); END;").unwrap(); | ||
| 2317 | drop(connection); | ||
| 2318 | let cache = Arc::new(Replica::open(&path).unwrap()); | ||
| 2319 | let server = Arc::new(Mutex::new(Server::new(&source))); | ||
| 2320 | let shared = Shared(Arc::clone(&server)); | ||
| 2321 | let (tx, rx) = mpsc::channel(); | ||
| 2322 | let worker = cache | ||
| 2323 | .start_sync( | ||
| 2324 | Duration::from_millis(1), | ||
| 2325 | move || Ok(shared.clone()), | ||
| 2326 | move |result| { | ||
| 2327 | tx.send(matches!(result, Err(Error::Database(_)))).unwrap(); | ||
| 2328 | }, | ||
| 2329 | ) | ||
| 2330 | .unwrap(); | ||
| 2331 | assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); | ||
| 2332 | assert!(matches!(worker.stop(), Err(Error::Database(_)))); | ||
| 2333 | assert!(rx.try_iter().next().is_none()); | ||
| 2334 | assert_eq!(server.lock().unwrap().publications, 0); | ||
| 2335 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 2336 | assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc"); | ||
| 2337 | } | ||
| 2338 | |||
| 2339 | #[test] | ||
| 2340 | fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() { | ||
| 2341 | for uncertain in [false, true] { | ||
| 2342 | let dir = tempfile::tempdir().unwrap(); | ||
| 2343 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2344 | let (sid, oid, _) = text(&source); | ||
| 2345 | let cache = | ||
| 2346 | Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2347 | let id = cache | ||
| 2348 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 2349 | .unwrap() | ||
| 2350 | .unwrap(); | ||
| 2351 | let local = cache.snapshot().unwrap(); | ||
| 2352 | let mut server = if uncertain { | ||
| 2353 | Server::new(&source) | ||
| 2354 | } else { | ||
| 2355 | Server::new(&onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap()) | ||
| 2356 | }; | ||
| 2357 | if uncertain { | ||
| 2358 | server.fault = Fault::UnknownBefore; | ||
| 2359 | } | ||
| 2360 | let server = Arc::new(Mutex::new(server)); | ||
| 2361 | let shared = Shared(Arc::clone(&server)); | ||
| 2362 | let (tx, rx) = mpsc::channel(); | ||
| 2363 | let worker = cache | ||
| 2364 | .start_sync( | ||
| 2365 | Duration::from_millis(10), | ||
| 2366 | move || Ok(shared.clone()), | ||
| 2367 | move |result| { | ||
| 2368 | tx.send(result.as_ref().copied().map_err(|_| ())).unwrap(); | ||
| 2369 | }, | ||
| 2370 | ) | ||
| 2371 | .unwrap(); | ||
| 2372 | if uncertain { | ||
| 2373 | assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); | ||
| 2374 | } | ||
| 2375 | let mut previous = None; | ||
| 2376 | let started = Instant::now(); | ||
| 2377 | for _ in 0..5 { | ||
| 2378 | let (actual, status) = rx | ||
| 2379 | .recv_timeout(Duration::from_secs(5)) | ||
| 2380 | .unwrap() | ||
| 2381 | .unwrap() | ||
| 2382 | .unwrap(); | ||
| 2383 | assert_eq!(actual, id); | ||
| 2384 | if uncertain { | ||
| 2385 | assert!(matches!(status, EditStatus::AwaitingConfirmation { .. })); | ||
| 2386 | } else { | ||
| 2387 | assert_eq!(status, EditStatus::Conflict(ConflictKind::TextChanged)); | ||
| 2388 | } | ||
| 2389 | if let Some(previous) = previous { | ||
| 2390 | assert_eq!(previous, status); | ||
| 2391 | } | ||
| 2392 | previous = Some(status); | ||
| 2393 | } | ||
| 2394 | assert!( | ||
| 2395 | started.elapsed() >= Duration::from_millis(30), | ||
| 2396 | "Worker spun instead of waiting between retries" | ||
| 2397 | ); | ||
| 2398 | worker.stop().unwrap(); | ||
| 2399 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 2400 | assert_eq!(cache.pending().unwrap().len(), 1); | ||
| 2401 | let server = server.lock().unwrap(); | ||
| 2402 | assert_eq!(server.publications, usize::from(uncertain)); | ||
| 2403 | assert_eq!(server.confirmations, 0); | ||
| 2404 | } | ||
| 2405 | } | ||
| 2406 | |||
| 2407 | #[test] | ||
| 2408 | fn reachability_notification_retries_without_waiting_for_the_poll() { | ||
| 2409 | let dir = tempfile::tempdir().unwrap(); | ||
| 2410 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2411 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2412 | let (tx, rx) = mpsc::channel(); | ||
| 2413 | let worker = cache | ||
| 2414 | .start_sync( | ||
| 2415 | Duration::from_secs(3600), | ||
| 2416 | || -> io::Result<Shared> { Err(io::ErrorKind::NotConnected.into()) }, | ||
| 2417 | move |result| { | ||
| 2418 | tx.send(matches!(result, Err(Error::RemoteIo(_)))).unwrap(); | ||
| 2419 | }, | ||
| 2420 | ) | ||
| 2421 | .unwrap(); | ||
| 2422 | assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); | ||
| 2423 | worker.wake(); | ||
| 2424 | assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap()); | ||
| 2425 | worker.stop().unwrap(); | ||
| 2426 | assert!(rx.try_iter().next().is_none()); | ||
| 2427 | } | ||
| 2428 | |||
| 2429 | #[test] | ||
| 2430 | fn cancellation_during_connect_does_not_read_or_report_a_false_refresh() { | ||
| 2431 | let dir = tempfile::tempdir().unwrap(); | ||
| 2432 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2433 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2434 | let (entered_tx, entered_rx) = mpsc::channel(); | ||
| 2435 | let (resume_tx, resume_rx) = mpsc::channel(); | ||
| 2436 | let (tx, rx) = mpsc::channel(); | ||
| 2437 | // An invalid image makes any unexpected read observable as an error callback. | ||
| 2438 | let shared = Shared(Arc::new(Mutex::new(Server::new(&[])))); | ||
| 2439 | let worker = cache | ||
| 2440 | .start_sync( | ||
| 2441 | Duration::from_secs(3600), | ||
| 2442 | move || { | ||
| 2443 | entered_tx.send(()).unwrap(); | ||
| 2444 | resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2445 | Ok(shared.clone()) | ||
| 2446 | }, | ||
| 2447 | move |_| { | ||
| 2448 | tx.send(()).unwrap(); | ||
| 2449 | }, | ||
| 2450 | ) | ||
| 2451 | .unwrap(); | ||
| 2452 | entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2453 | drop(worker); | ||
| 2454 | let weak = Arc::downgrade(&cache); | ||
| 2455 | drop(cache); | ||
| 2456 | resume_tx.send(()).unwrap(); | ||
| 2457 | let started = Instant::now(); | ||
| 2458 | while weak.upgrade().is_some() { | ||
| 2459 | assert!(started.elapsed() < Duration::from_secs(5)); | ||
| 2460 | std::thread::sleep(Duration::from_millis(1)); | ||
| 2461 | } | ||
| 2462 | assert_eq!( | ||
| 2463 | rx.recv_timeout(Duration::from_secs(5)), | ||
| 2464 | Err(mpsc::RecvTimeoutError::Disconnected) | ||
| 2465 | ); | ||
| 2466 | } | ||
| 2467 | |||
| 2468 | #[test] | ||
| 2469 | fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() { | ||
| 2470 | let dir = tempfile::tempdir().unwrap(); | ||
| 2471 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2472 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2473 | for interval in [Duration::ZERO, Duration::MAX] { | ||
| 2474 | assert!( | ||
| 2475 | matches!(cache.start_sync(interval, || -> io::Result<Shared> { panic!("Unexpected connection") }, |_| {}), Err(error) if error.kind() == io::ErrorKind::InvalidInput) | ||
| 2476 | ); | ||
| 2477 | } | ||
| 2478 | let shared = Shared(Arc::new(Mutex::new(Server::new(&source)))); | ||
| 2479 | let first = shared.clone(); | ||
| 2480 | let (tx, rx) = mpsc::channel(); | ||
| 2481 | let worker = cache | ||
| 2482 | .start_sync( | ||
| 2483 | Duration::from_secs(3600), | ||
| 2484 | move || Ok(first.clone()), | ||
| 2485 | move |_| { | ||
| 2486 | tx.send(()).unwrap(); | ||
| 2487 | panic!("Test observer panic"); | ||
| 2488 | }, | ||
| 2489 | ) | ||
| 2490 | .unwrap(); | ||
| 2491 | rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2492 | assert!(matches!(worker.stop(), Err(Error::Io(_)))); | ||
| 2493 | let (tx, rx) = mpsc::channel(); | ||
| 2494 | let worker = cache | ||
| 2495 | .start_sync( | ||
| 2496 | Duration::from_secs(3600), | ||
| 2497 | move || Ok(shared.clone()), | ||
| 2498 | move |result| { | ||
| 2499 | tx.send(*result.as_ref().unwrap()).unwrap(); | ||
| 2500 | }, | ||
| 2501 | ) | ||
| 2502 | .unwrap(); | ||
| 2503 | assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), None); | ||
| 2504 | worker.stop().unwrap(); | ||
| 2505 | assert_eq!(cache.snapshot().unwrap(), source); | ||
| 2506 | } | ||
| 2507 | |||
| 2508 | #[test] | ||
| 2509 | fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() { | ||
| 2510 | let dir = tempfile::tempdir().unwrap(); | ||
| 2511 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | ||
| 2512 | let (sid, oid, _) = text(&source); | ||
| 2513 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2514 | let id = cache | ||
| 2515 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 2516 | .unwrap() | ||
| 2517 | .unwrap(); | ||
| 2518 | let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); | ||
| 2519 | let server = Arc::new(Mutex::new(Server::new(&remote))); | ||
| 2520 | let shared = Shared(Arc::clone(&server)); | ||
| 2521 | let (tx, rx) = mpsc::channel(); | ||
| 2522 | let (resume_tx, resume_rx) = mpsc::channel(); | ||
| 2523 | let mut first = true; | ||
| 2524 | let worker = cache | ||
| 2525 | .start_sync( | ||
| 2526 | Duration::from_secs(3600), | ||
| 2527 | move || Ok(shared.clone()), | ||
| 2528 | move |result| { | ||
| 2529 | tx.send(*result.as_ref().unwrap()).unwrap(); | ||
| 2530 | if first { | ||
| 2531 | first = false; | ||
| 2532 | resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2533 | } | ||
| 2534 | }, | ||
| 2535 | ) | ||
| 2536 | .unwrap(); | ||
| 2537 | assert_eq!( | ||
| 2538 | rx.recv_timeout(Duration::from_secs(5)).unwrap(), | ||
| 2539 | Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) | ||
| 2540 | ); | ||
| 2541 | cache | ||
| 2542 | .rebase_conflict( | ||
| 2543 | id, | ||
| 2544 | &cache.snapshot().unwrap(), | ||
| 2545 | &cache.remote_snapshot().unwrap(), | ||
| 2546 | 1..2, | ||
| 2547 | ) | ||
| 2548 | .unwrap(); | ||
| 2549 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 2550 | resume_tx.send(()).unwrap(); | ||
| 2551 | assert!( | ||
| 2552 | matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | ||
| 2553 | ); | ||
| 2554 | worker.stop().unwrap(); | ||
| 2555 | assert!(cache.pending().unwrap().is_empty()); | ||
| 2556 | let server = server.lock().unwrap(); | ||
| 2557 | assert_eq!(server.publications, 1); | ||
| 2558 | assert_eq!(text(&server.durable).2, "aLc"); | ||
| 2559 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 2560 | } | ||
| 2561 | |||
| 2562 | #[test] | ||
| 2563 | fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() { | ||
| 2564 | struct Busy { | ||
| 2565 | server: Server, | ||
| 2566 | reads: usize, | ||
| 2567 | writes: usize, | ||
| 2568 | } | ||
| 2569 | impl Remote for Busy { | ||
| 2570 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 2571 | self.reads += 1; | ||
| 2572 | match self.reads { | ||
| 2573 | 1 => Err(io::ErrorKind::WouldBlock.into()), | ||
| 2574 | 2 => Err(io::ErrorKind::ResourceBusy.into()), | ||
| 2575 | _ => self.server.read(), | ||
| 2576 | } | ||
| 2577 | } | ||
| 2578 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 2579 | self.writes += 1; | ||
| 2580 | match self.writes { | ||
| 2581 | 1 | 2 => Err(CommitError { | ||
| 2582 | state: CommitState::NotCommitted, | ||
| 2583 | error: if self.writes == 1 { | ||
| 2584 | io::ErrorKind::WouldBlock | ||
| 2585 | } else { | ||
| 2586 | io::ErrorKind::ResourceBusy | ||
| 2587 | } | ||
| 2588 | .into(), | ||
| 2589 | }), | ||
| 2590 | _ => self.server.publish(edit), | ||
| 2591 | } | ||
| 2592 | } | ||
| 2593 | fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { | ||
| 2594 | self.server.confirm(source) | ||
| 2595 | } | ||
| 2596 | } | ||
| 2597 | let dir = tempfile::tempdir().unwrap(); | ||
| 2598 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2599 | let (sid, oid, _) = text(&source); | ||
| 2600 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2601 | let id = cache | ||
| 2602 | .edit_text(&source, sid, oid, 0..0, "L ") | ||
| 2603 | .unwrap() | ||
| 2604 | .unwrap(); | ||
| 2605 | let mut remote = Some(Busy { | ||
| 2606 | server: Server::new(&source), | ||
| 2607 | reads: 0, | ||
| 2608 | writes: 0, | ||
| 2609 | }); | ||
| 2610 | let (tx, rx) = mpsc::channel(); | ||
| 2611 | let worker = cache | ||
| 2612 | .start_sync( | ||
| 2613 | Duration::from_millis(1), | ||
| 2614 | move || Ok(remote.take().expect("Contention caused a reconnect")), | ||
| 2615 | move |result| { | ||
| 2616 | tx.send(result.as_ref().copied().map_err(|error| error.to_string())) | ||
| 2617 | .unwrap(); | ||
| 2618 | }, | ||
| 2619 | ) | ||
| 2620 | .unwrap(); | ||
| 2621 | for _ in 0..4 { | ||
| 2622 | assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err()); | ||
| 2623 | } | ||
| 2624 | assert!( | ||
| 2625 | matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | ||
| 2626 | ); | ||
| 2627 | worker.stop().unwrap(); | ||
| 2628 | assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc"); | ||
| 2629 | assert!(cache.pending().unwrap().is_empty()); | ||
| 2630 | } | ||
| 2631 | #[test] | ||
| 2632 | fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() { | ||
| 2633 | struct BusyOnce(Server); | ||
| 2634 | impl Remote for BusyOnce { | ||
| 2635 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 2636 | self.0.read() | ||
| 2637 | } | ||
| 2638 | fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { | ||
| 2639 | let server = &mut self.0; | ||
| 2640 | if server.publications == 0 { | ||
| 2641 | server.publications += 1; | ||
| 2642 | return Err(CommitError { | ||
| 2643 | state: CommitState::NotCommitted, | ||
| 2644 | error: io::ErrorKind::ResourceBusy.into(), | ||
| 2645 | }); | ||
| 2646 | } | ||
| 2647 | server.publish(edit) | ||
| 2648 | } | ||
| 2649 | fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { | ||
| 2650 | self.0.confirm(source) | ||
| 2651 | } | ||
| 2652 | } | ||
| 2653 | let dir = tempfile::tempdir().unwrap(); | ||
| 2654 | let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap(); | ||
| 2655 | let (sid, oid, _) = text(&source); | ||
| 2656 | let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap()); | ||
| 2657 | let first = cache | ||
| 2658 | .edit_text(&source, sid, oid, 0..0, "L ") | ||
| 2659 | .unwrap() | ||
| 2660 | .unwrap(); | ||
| 2661 | let mut remote = Some(BusyOnce(Server::new(&source))); | ||
| 2662 | let observed = Arc::clone(&cache); | ||
| 2663 | let (tx, rx) = mpsc::channel(); | ||
| 2664 | let mut failed = false; | ||
| 2665 | let worker = cache | ||
| 2666 | .start_sync( | ||
| 2667 | Duration::from_secs(3600), | ||
| 2668 | move || Ok(remote.take().expect("Contention must retain the session")), | ||
| 2669 | move |result| match result { | ||
| 2670 | Err(Error::Remote(error)) if error.state == CommitState::NotCommitted => { | ||
| 2671 | assert!(!failed); | ||
| 2672 | failed = true; | ||
| 2673 | let source = observed.snapshot().unwrap(); | ||
| 2674 | let second = observed | ||
| 2675 | .edit_text(&source, sid, oid, 0..0, "Q ") | ||
| 2676 | .unwrap() | ||
| 2677 | .unwrap(); | ||
| 2678 | tx.send((second, None)).unwrap(); | ||
| 2679 | } | ||
| 2680 | Ok(Some((id, status))) => tx.send((*id, Some(*status))).unwrap(), | ||
| 2681 | Ok(None) => {} | ||
| 2682 | other => panic!("Unexpected worker result: {other:?}"), | ||
| 2683 | }, | ||
| 2684 | ) | ||
| 2685 | .unwrap(); | ||
| 2686 | let (second, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2687 | assert_eq!(status, None); | ||
| 2688 | for expected in [first, second] { | ||
| 2689 | let (id, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap(); | ||
| 2690 | assert_eq!(id, expected); | ||
| 2691 | assert!(matches!(status, Some(EditStatus::Published { .. }))); | ||
| 2692 | } | ||
| 2693 | worker.stop().unwrap(); | ||
| 2694 | assert!(cache.pending().unwrap().is_empty()); | ||
| 2695 | assert_eq!(text(&cache.snapshot().unwrap()).2, "Q L abc"); | ||
| 2696 | } | ||
| 2697 | } | ||
| 2698 | |||
| 2699 | #[test] | ||
| 2700 | fn offline_insertions_survive_reopen_rebase_and_dependent_text_edits() { | ||
| 2701 | use onestore::Insertion; | ||
| 2702 | let dir = tempfile::tempdir().unwrap(); | ||
| 2703 | let path = dir.path().join("insert.sqlite"); | ||
| 2704 | let source = onestore::create_section("insert.one", "Original", "Author").unwrap(); | ||
| 2705 | let (sid, original, _) = text(&source); | ||
| 2706 | let store = Store::parse(&source).unwrap(); | ||
| 2707 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 2708 | let doc = Document::parse(&index).unwrap(); | ||
| 2709 | let (_, page) = doc.pages().unwrap()[0]; | ||
| 2710 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 2711 | let outline = Insertion::outline(page, 72.0, 144.0, "Offline outline", "Offline author") | ||
| 2712 | .unwrap() | ||
| 2713 | .with_formatting(0..7, &[onestore::TextAttribute::Italic(true)]) | ||
| 2714 | .unwrap(); | ||
| 2715 | let first = cache.insert(&source, sid, &outline).unwrap().unwrap(); | ||
| 2716 | let snapshot = cache.snapshot().unwrap(); | ||
| 2717 | let paragraph = Insertion::paragraph( | ||
| 2718 | outline.object(), | ||
| 2719 | None, | ||
| 2720 | "Offline paragraph 🦀", | ||
| 2721 | "Offline author", | ||
| 2722 | ) | ||
| 2723 | .unwrap() | ||
| 2724 | .with_formatting(8..17, &[onestore::TextAttribute::Bold(true)]) | ||
| 2725 | .unwrap(); | ||
| 2726 | let second = cache.insert(&snapshot, sid, &paragraph).unwrap().unwrap(); | ||
| 2727 | let third = cache | ||
| 2728 | .edit_text( | ||
| 2729 | &cache.snapshot().unwrap(), | ||
| 2730 | sid, | ||
| 2731 | paragraph.text_object(), | ||
| 2732 | 0..0, | ||
| 2733 | "Edited ", | ||
| 2734 | ) | ||
| 2735 | .unwrap() | ||
| 2736 | .unwrap(); | ||
| 2737 | let local = cache.snapshot().unwrap(); | ||
| 2738 | let pending = cache.pending().unwrap(); | ||
| 2739 | drop(cache); | ||
| 2740 | let cache = Replica::open(&path).unwrap(); | ||
| 2741 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 2742 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 2743 | let remote = onestore::replace_text(&source, sid, original, 0..0, "Remote ").unwrap(); | ||
| 2744 | let mut server = Server::new(&remote); | ||
| 2745 | for id in [first, second, third] { | ||
| 2746 | assert!( | ||
| 2747 | matches!(cache.sync_once(&mut server).unwrap(),Some((observed,EditStatus::Published{..})) if observed==id) | ||
| 2748 | ); | ||
| 2749 | } | ||
| 2750 | assert_eq!(server.publications, 3); | ||
| 2751 | assert!(cache.pending().unwrap().is_empty()); | ||
| 2752 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 2753 | let store = Store::parse(&server.durable).unwrap(); | ||
| 2754 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 2755 | let doc = Document::parse(&index).unwrap(); | ||
| 2756 | let s = &doc.spaces[&sid]; | ||
| 2757 | let v = &s.revisions[&s.contexts[&ExGuid::default()]]; | ||
| 2758 | for (id, wanted) in [ | ||
| 2759 | (original, "Remote Original"), | ||
| 2760 | (outline.text_object(), "Offline outline"), | ||
| 2761 | (paragraph.text_object(), "Edited Offline paragraph 🦀"), | ||
| 2762 | ] { | ||
| 2763 | assert!(matches!(&v.nodes[&id].kind,Kind::RichText{text,..} if text==wanted)); | ||
| 2764 | } | ||
| 2765 | let outline_runs = v.text_runs(outline.text_object()).unwrap(); | ||
| 2766 | assert_eq!(outline_runs[0].text, "Offline"); | ||
| 2767 | assert_eq!(outline_runs[0].format.italic, Some(true)); | ||
| 2768 | let runs = v.text_runs(paragraph.text_object()).unwrap(); | ||
| 2769 | assert_eq!(runs[1].text, "paragraph"); | ||
| 2770 | assert_eq!(runs[1].format.bold, Some(true)); | ||
| 2771 | assert_eq!( | ||
| 2772 | v.nodes[&outline.object()].children.last(), | ||
| 2773 | Some(&paragraph.object()) | ||
| 2774 | ); | ||
| 2775 | } | ||
| 2776 | |||
| 2777 | #[test] | ||
| 2778 | fn uncertain_insertions_reconcile_the_original_revision_without_duplicate_objects() { | ||
| 2779 | use onestore::Insertion; | ||
| 2780 | for fault in [ | ||
| 2781 | Fault::Before, | ||
| 2782 | Fault::UnknownBefore, | ||
| 2783 | Fault::UnknownAfter, | ||
| 2784 | Fault::PanicBefore, | ||
| 2785 | Fault::PanicAfter, | ||
| 2786 | Fault::Committed, | ||
| 2787 | ] { | ||
| 2788 | let dir = tempfile::tempdir().unwrap(); | ||
| 2789 | let path = dir.path().join("uncertain-insert.sqlite"); | ||
| 2790 | let source = onestore::create_section("insert.one", "Original", "Author").unwrap(); | ||
| 2791 | let store = Store::parse(&source).unwrap(); | ||
| 2792 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 2793 | let doc = Document::parse(&index).unwrap(); | ||
| 2794 | let (sid, page) = doc.pages().unwrap()[0]; | ||
| 2795 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 2796 | let insertion = Insertion::outline(page, 144.0, 144.0, "Uncertain insertion", "Author") | ||
| 2797 | .unwrap() | ||
| 2798 | .with_formatting(0..9, &[onestore::TextAttribute::Bold(true)]) | ||
| 2799 | .unwrap() | ||
| 2800 | .with_formatting(10..19, &[onestore::TextAttribute::Italic(true)]) | ||
| 2801 | .unwrap(); | ||
| 2802 | let id = cache.insert(&source, sid, &insertion).unwrap().unwrap(); | ||
| 2803 | let local = cache.snapshot().unwrap(); | ||
| 2804 | let mut server = Server::new(&source); | ||
| 2805 | server.fault = fault; | ||
| 2806 | let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | ||
| 2807 | cache.sync_once(&mut server) | ||
| 2808 | })); | ||
| 2809 | drop(cache); | ||
| 2810 | let cache = Replica::open(&path).unwrap(); | ||
| 2811 | assert_eq!(server.publications, 1); | ||
| 2812 | if matches!(fault, Fault::UnknownBefore | Fault::PanicBefore) { | ||
| 2813 | let status = cache.status(id).unwrap(); | ||
| 2814 | assert!(matches!( | ||
| 2815 | status, | ||
| 2816 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 2817 | )); | ||
| 2818 | for _ in 0..5 { | ||
| 2819 | assert_eq!( | ||
| 2820 | cache.sync_once(&mut server).unwrap(), | ||
| 2821 | status.map(|state| (id, state)) | ||
| 2822 | ); | ||
| 2823 | } | ||
| 2824 | assert_eq!(server.publications, 1); | ||
| 2825 | assert_eq!(server.durable, source); | ||
| 2826 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 2827 | } else { | ||
| 2828 | if !matches!(fault, Fault::Committed) { | ||
| 2829 | assert!(matches!( | ||
| 2830 | cache.sync_once(&mut server).unwrap(), | ||
| 2831 | Some((_, EditStatus::Published { .. })) | ||
| 2832 | )); | ||
| 2833 | } | ||
| 2834 | assert_eq!( | ||
| 2835 | server.publications, | ||
| 2836 | if matches!(fault, Fault::Before) { 2 } else { 1 } | ||
| 2837 | ); | ||
| 2838 | assert_eq!( | ||
| 2839 | server.confirmations, | ||
| 2840 | usize::from(matches!(fault, Fault::UnknownAfter | Fault::PanicAfter)) | ||
| 2841 | ); | ||
| 2842 | let store = Store::parse(&server.durable).unwrap(); | ||
| 2843 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 2844 | let doc = Document::parse(&index).unwrap(); | ||
| 2845 | let s = &doc.spaces[&sid]; | ||
| 2846 | let v = &s.revisions[&s.contexts[&ExGuid::default()]]; | ||
| 2847 | assert_eq!(v.nodes.values().filter(|node|matches!(&node.kind,Kind::RichText{text,..} if text=="Uncertain insertion")).count(),1); | ||
| 2848 | assert!(v.nodes.contains_key(&insertion.object())); | ||
| 2849 | let runs = v.text_runs(insertion.text_object()).unwrap(); | ||
| 2850 | assert_eq!(runs.len(), 3); | ||
| 2851 | assert_eq!(runs[0].text, "Uncertain"); | ||
| 2852 | assert_eq!(runs[0].format.bold, Some(true)); | ||
| 2853 | assert_eq!(runs[1].text, " "); | ||
| 2854 | assert_ne!(runs[1].format.bold, Some(true)); | ||
| 2855 | assert_ne!(runs[1].format.italic, Some(true)); | ||
| 2856 | assert_eq!(runs[2].text, "insertion"); | ||
| 2857 | assert_eq!(runs[2].format.italic, Some(true)); | ||
| 2858 | assert!(cache.pending().unwrap().is_empty()); | ||
| 2859 | } | ||
| 2860 | } | ||
| 2861 | } | ||
| 2862 | |||
| 2863 | #[test] | ||
| 2864 | fn cross_run_text_rebases_preserve_remote_styles_and_survive_lost_replies() { | ||
| 2865 | use onestore::TextAttribute as A; | ||
| 2866 | for fault in [Fault::None, Fault::UnknownAfter, Fault::PanicAfter] { | ||
| 2867 | let directory = tempfile::tempdir().unwrap(); | ||
| 2868 | let path = directory.path().join("cross-run.sqlite"); | ||
| 2869 | let plain = onestore::create_section("cross.one", "ab🦀cde\u{301}fg", "Author").unwrap(); | ||
| 2870 | let (sid, oid, _) = text(&plain); | ||
| 2871 | let bold = PreparedEdit::format(&plain, sid, oid, 1..4, &[A::Bold(true)]).unwrap(); | ||
| 2872 | let source = PreparedEdit::format(bold.as_bytes(), sid, oid, 4..7, &[A::Italic(true)]) | ||
| 2873 | .unwrap() | ||
| 2874 | .as_bytes() | ||
| 2875 | .to_vec(); | ||
| 2876 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 2877 | let first = cache | ||
| 2878 | .edit_text(&source, sid, oid, 2..7, "日本語") | ||
| 2879 | .unwrap() | ||
| 2880 | .unwrap(); | ||
| 2881 | let local = cache.snapshot().unwrap(); | ||
| 2882 | let second = cache | ||
| 2883 | .edit_text(&local, sid, oid, 3..4, "🐈") | ||
| 2884 | .unwrap() | ||
| 2885 | .unwrap(); | ||
| 2886 | let pending = cache.pending().unwrap(); | ||
| 2887 | drop(cache); | ||
| 2888 | let cache = Replica::open(&path).unwrap(); | ||
| 2889 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 2890 | let prefix = PreparedEdit::text(&source, sid, oid, 0..0, "Prefix ").unwrap(); | ||
| 2891 | let remote = | ||
| 2892 | PreparedEdit::format(prefix.as_bytes(), sid, oid, 9..11, &[A::Underline(true)]) | ||
| 2893 | .unwrap(); | ||
| 2894 | let mut server = Server::new(remote.as_bytes()); | ||
| 2895 | server.fault = fault; | ||
| 2896 | let attempted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | ||
| 2897 | cache.sync_once(&mut server) | ||
| 2898 | })); | ||
| 2899 | if matches!(fault, Fault::None) { | ||
| 2900 | assert!( | ||
| 2901 | matches!(attempted.unwrap().unwrap(), Some((id, EditStatus::Published { .. })) if id == first) | ||
| 2902 | ); | ||
| 2903 | } else { | ||
| 2904 | assert!(matches!( | ||
| 2905 | cache.status(first).unwrap(), | ||
| 2906 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 2907 | )); | ||
| 2908 | } | ||
| 2909 | drop(cache); | ||
| 2910 | let cache = Replica::open(&path).unwrap(); | ||
| 2911 | while let Some((_, status)) = cache.sync_once(&mut server).unwrap() { | ||
| 2912 | assert!(matches!(status, EditStatus::Published { .. })); | ||
| 2913 | } | ||
| 2914 | assert_eq!(server.publications, 2); | ||
| 2915 | assert!(matches!( | ||
| 2916 | cache.status(second).unwrap(), | ||
| 2917 | Some(EditStatus::Published { .. }) | ||
| 2918 | )); | ||
| 2919 | assert_eq!(text(&server.durable).2, "Prefix ab日🐈語\u{301}fg"); | ||
| 2920 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 2921 | let store = Store::parse(&server.durable).unwrap(); | ||
| 2922 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 2923 | let document = Document::parse(&index).unwrap(); | ||
| 2924 | let space = &document.spaces[&sid]; | ||
| 2925 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 2926 | let runs = revision.text_runs(oid).unwrap(); | ||
| 2927 | let replacement = runs.iter().find(|run| run.text == "日🐈語").unwrap(); | ||
| 2928 | assert_eq!(replacement.format.bold, Some(true)); | ||
| 2929 | assert_eq!(replacement.format.underline, Some(true)); | ||
| 2930 | assert_ne!(replacement.format.italic, Some(true)); | ||
| 2931 | let suffix = runs.last().unwrap(); | ||
| 2932 | assert_eq!(suffix.text, "\u{301}fg"); | ||
| 2933 | assert_ne!(suffix.format.bold, Some(true)); | ||
| 2934 | assert_ne!(suffix.format.underline, Some(true)); | ||
| 2935 | assert_ne!(suffix.format.italic, Some(true)); | ||
| 2936 | } | ||
| 2937 | } | ||
| 2938 | |||
| 2939 | #[test] | ||
| 2940 | fn offline_formatting_rebases_text_and_merges_independent_attributes() { | ||
| 2941 | use onestore::TextAttribute as A; | ||
| 2942 | let directory = tempfile::tempdir().unwrap(); | ||
| 2943 | let path = directory.path().join("format.sqlite"); | ||
| 2944 | let source = onestore::create_section("format.one", "ab🦀cd", "Author").unwrap(); | ||
| 2945 | let (sid, id, _) = text(&source); | ||
| 2946 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 2947 | let local = cache | ||
| 2948 | .format(&source, sid, id, 2..4, &[A::Bold(true)]) | ||
| 2949 | .unwrap() | ||
| 2950 | .unwrap(); | ||
| 2951 | let pending = cache.pending().unwrap(); | ||
| 2952 | drop(cache); | ||
| 2953 | let cache = Replica::open(&path).unwrap(); | ||
| 2954 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 2955 | let moved = PreparedEdit::text(&source, sid, id, 0..0, "Prefix ").unwrap(); | ||
| 2956 | let styled = | ||
| 2957 | PreparedEdit::format(moved.as_bytes(), sid, id, 9..11, &[A::Italic(true)]).unwrap(); | ||
| 2958 | let mut server = Server::new(styled.as_bytes()); | ||
| 2959 | assert!( | ||
| 2960 | matches!(cache.sync_once(&mut server).unwrap(),Some((observed,EditStatus::Published{..}))if observed==local) | ||
| 2961 | ); | ||
| 2962 | let store = Store::parse(&server.durable).unwrap(); | ||
| 2963 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 2964 | let doc = Document::parse(&index).unwrap(); | ||
| 2965 | let space = &doc.spaces[&sid]; | ||
| 2966 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 2967 | let runs = view.text_runs(id).unwrap(); | ||
| 2968 | let selected = runs.iter().find(|r| r.text == "🦀").unwrap(); | ||
| 2969 | assert_eq!(selected.format.bold, Some(true)); | ||
| 2970 | assert_eq!(selected.format.italic, Some(true)); | ||
| 2971 | assert_eq!( | ||
| 2972 | runs.iter().map(|r| r.text).collect::<String>(), | ||
| 2973 | "Prefix ab🦀cd" | ||
| 2974 | ); | ||
| 2975 | } | ||
| 2976 | |||
| 2977 | #[test] | ||
| 2978 | fn competing_font_changes_remain_preserved_conflicts() { | ||
| 2979 | use onestore::TextAttribute as A; | ||
| 2980 | let directory = tempfile::tempdir().unwrap(); | ||
| 2981 | let path = directory.path().join("conflict.sqlite"); | ||
| 2982 | let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); | ||
| 2983 | let (sid, id, _) = text(&source); | ||
| 2984 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 2985 | let local_id = cache | ||
| 2986 | .format(&source, sid, id, 1..5, &[A::FontSize(14.0)]) | ||
| 2987 | .unwrap() | ||
| 2988 | .unwrap(); | ||
| 2989 | let local = cache.snapshot().unwrap(); | ||
| 2990 | let pending = cache.pending().unwrap(); | ||
| 2991 | let remote = PreparedEdit::format(&source, sid, id, 2..4, &[A::FontSize(18.0)]).unwrap(); | ||
| 2992 | let mut server = Server::new(remote.as_bytes()); | ||
| 2993 | for _ in 0..3 { | ||
| 2994 | assert_eq!( | ||
| 2995 | cache.sync_once(&mut server).unwrap(), | ||
| 2996 | Some(( | ||
| 2997 | local_id, | ||
| 2998 | EditStatus::Conflict(ConflictKind::FormattingChanged) | ||
| 2999 | )) | ||
| 3000 | ); | ||
| 3001 | } | ||
| 3002 | assert_eq!(server.publications, 0); | ||
| 3003 | assert_eq!(server.confirmations, 0); | ||
| 3004 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3005 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 3006 | drop(cache); | ||
| 3007 | let cache = Replica::open(&path).unwrap(); | ||
| 3008 | assert_eq!( | ||
| 3009 | cache.status(local_id).unwrap(), | ||
| 3010 | Some(EditStatus::Conflict(ConflictKind::FormattingChanged)) | ||
| 3011 | ); | ||
| 3012 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3013 | } | ||
| 3014 | |||
| 3015 | #[test] | ||
| 3016 | fn independently_satisfied_formatting_requires_confirmation_before_a_receipt() { | ||
| 3017 | use onestore::TextAttribute as A; | ||
| 3018 | let directory = tempfile::tempdir().unwrap(); | ||
| 3019 | let path = directory.path().join("satisfied.sqlite"); | ||
| 3020 | let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); | ||
| 3021 | let (sid, id, _) = text(&source); | ||
| 3022 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3023 | let local_id = cache | ||
| 3024 | .format(&source, sid, id, 1..5, &[A::Bold(true)]) | ||
| 3025 | .unwrap() | ||
| 3026 | .unwrap(); | ||
| 3027 | let remote = PreparedEdit::format(&source, sid, id, 1..5, &[A::Bold(true)]).unwrap(); | ||
| 3028 | let mut server = Server::new(remote.as_bytes()); | ||
| 3029 | server.durable = source.clone(); | ||
| 3030 | server.fault = Fault::Confirm; | ||
| 3031 | assert!( | ||
| 3032 | matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown) | ||
| 3033 | ); | ||
| 3034 | assert_eq!(cache.status(local_id).unwrap(), Some(EditStatus::Pending)); | ||
| 3035 | assert_eq!(server.publications, 0); | ||
| 3036 | assert_eq!(server.durable, source); | ||
| 3037 | drop(cache); | ||
| 3038 | let cache = Replica::open(&path).unwrap(); | ||
| 3039 | assert!( | ||
| 3040 | matches!(cache.sync_once(&mut server).unwrap(),Some((id,EditStatus::Published{..}))if id==local_id) | ||
| 3041 | ); | ||
| 3042 | assert_eq!(server.publications, 0); | ||
| 3043 | assert_eq!(server.confirmations, 2); | ||
| 3044 | assert_ne!(server.durable, source); | ||
| 3045 | assert!(cache.pending().unwrap().is_empty()); | ||
| 3046 | } | ||
| 3047 | |||
| 3048 | #[test] | ||
| 3049 | fn retired_format_attempts_require_the_complete_durable_effect_without_replay() { | ||
| 3050 | use onestore::TextAttribute as A; | ||
| 3051 | for (complete, fault) in [ | ||
| 3052 | (true, Fault::None), | ||
| 3053 | (true, Fault::Confirm), | ||
| 3054 | (true, Fault::ConfirmCommitted), | ||
| 3055 | (false, Fault::None), | ||
| 3056 | ] { | ||
| 3057 | let directory = tempfile::tempdir().unwrap(); | ||
| 3058 | let path = directory.path().join("retired-format.sqlite"); | ||
| 3059 | let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); | ||
| 3060 | let (sid, object, _) = text(&source); | ||
| 3061 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3062 | let id = cache | ||
| 3063 | .format( | ||
| 3064 | &source, | ||
| 3065 | sid, | ||
| 3066 | object, | ||
| 3067 | 1..5, | ||
| 3068 | &[A::Bold(true), A::FontSize(18.0)], | ||
| 3069 | ) | ||
| 3070 | .unwrap() | ||
| 3071 | .unwrap(); | ||
| 3072 | let local = cache.snapshot().unwrap(); | ||
| 3073 | let mut server = Server::new(&source); | ||
| 3074 | server.fault = Fault::UnknownAfter; | ||
| 3075 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 3076 | let attempted = cache.status(id).unwrap(); | ||
| 3077 | let Some(EditStatus::AwaitingConfirmation { revision: retired }) = attempted else { | ||
| 3078 | panic!() | ||
| 3079 | }; | ||
| 3080 | drop(cache); | ||
| 3081 | let prefix = PreparedEdit::text(&source, sid, object, 0..0, "prefix ").unwrap(); | ||
| 3082 | let mut attributes = vec![A::Bold(true), A::Italic(true)]; | ||
| 3083 | if complete { | ||
| 3084 | attributes.push(A::FontSize(18.0)); | ||
| 3085 | } | ||
| 3086 | server.visible = PreparedEdit::format(prefix.as_bytes(), sid, object, 8..12, &attributes) | ||
| 3087 | .unwrap() | ||
| 3088 | .as_bytes() | ||
| 3089 | .to_vec(); | ||
| 3090 | let store = Store::parse(&server.visible).unwrap(); | ||
| 3091 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3092 | let current = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; | ||
| 3093 | assert!(!index.spaces[&sid].revisions.contains_key(&retired)); | ||
| 3094 | let cache = Replica::open(&path).unwrap(); | ||
| 3095 | server.fault = fault; | ||
| 3096 | let result = cache.sync_once(&mut server); | ||
| 3097 | if !complete { | ||
| 3098 | assert_eq!(result.unwrap(), attempted.map(|s| (id, s))); | ||
| 3099 | assert_eq!(server.confirmations, 0); | ||
| 3100 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3101 | assert_eq!(server.durable, source); | ||
| 3102 | } else { | ||
| 3103 | if matches!(fault, Fault::Confirm) { | ||
| 3104 | assert!(matches!(result, Err(Error::Remote(e)) if e.state == CommitState::Unknown)); | ||
| 3105 | assert_eq!(cache.status(id).unwrap(), attempted); | ||
| 3106 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3107 | assert_eq!(server.durable, source); | ||
| 3108 | let complete = server.visible.clone(); | ||
| 3109 | server.visible = | ||
| 3110 | PreparedEdit::format(&complete, sid, object, 8..12, &[A::Bold(false)]) | ||
| 3111 | .unwrap() | ||
| 3112 | .as_bytes() | ||
| 3113 | .to_vec(); | ||
| 3114 | assert_eq!( | ||
| 3115 | cache.sync_once(&mut server).unwrap(), | ||
| 3116 | attempted.map(|s| (id, s)) | ||
| 3117 | ); | ||
| 3118 | assert_eq!(server.confirmations, 1); | ||
| 3119 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3120 | server.visible = complete; | ||
| 3121 | cache.sync_once(&mut server).unwrap(); | ||
| 3122 | } else if matches!(fault, Fault::ConfirmCommitted) { | ||
| 3123 | assert!( | ||
| 3124 | matches!(result, Err(Error::Remote(e)) if e.state == CommitState::Committed) | ||
| 3125 | ); | ||
| 3126 | } else { | ||
| 3127 | assert_eq!( | ||
| 3128 | result.unwrap(), | ||
| 3129 | Some((id, EditStatus::Published { revision: current })) | ||
| 3130 | ); | ||
| 3131 | } | ||
| 3132 | assert_ne!(current, retired); | ||
| 3133 | assert_eq!( | ||
| 3134 | cache.status(id).unwrap(), | ||
| 3135 | Some(EditStatus::Published { revision: current }) | ||
| 3136 | ); | ||
| 3137 | assert!(cache.pending().unwrap().is_empty()); | ||
| 3138 | assert_eq!(text(&server.durable).2, "prefix abcdef"); | ||
| 3139 | assert_ne!(server.durable, source); | ||
| 3140 | drop(cache); | ||
| 3141 | let cache = Replica::open(&path).unwrap(); | ||
| 3142 | assert_eq!( | ||
| 3143 | cache.status(id).unwrap(), | ||
| 3144 | Some(EditStatus::Published { revision: current }) | ||
| 3145 | ); | ||
| 3146 | } | ||
| 3147 | assert_eq!(server.publications, 1); | ||
| 3148 | } | ||
| 3149 | } | ||
| 3150 | |||
| 3151 | #[test] | ||
| 3152 | fn retired_text_with_equal_plaintext_but_a_different_surviving_run_remains_uncertain() { | ||
| 3153 | use onestore::TextAttribute; | ||
| 3154 | |||
| 3155 | let directory = tempfile::tempdir().unwrap(); | ||
| 3156 | let path = directory.path().join("repeated.sqlite"); | ||
| 3157 | let plain = onestore::create_section("repeated.one", "aa", "Fixture").unwrap(); | ||
| 3158 | let (space, object, _) = text(&plain); | ||
| 3159 | let source = PreparedEdit::format(&plain, space, object, 0..1, &[TextAttribute::Bold(true)]) | ||
| 3160 | .unwrap() | ||
| 3161 | .as_bytes() | ||
| 3162 | .to_vec(); | ||
| 3163 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3164 | let id = cache | ||
| 3165 | .edit_text(&source, space, object, 0..1, "") | ||
| 3166 | .unwrap() | ||
| 3167 | .unwrap(); | ||
| 3168 | let local = cache.snapshot().unwrap(); | ||
| 3169 | let mut server = Server::new(&source); | ||
| 3170 | server.fault = Fault::UnknownAfter; | ||
| 3171 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 3172 | let attempt = cache.status(id).unwrap(); | ||
| 3173 | server.visible = onestore::replace_text(&source, space, object, 1..2, "").unwrap(); | ||
| 3174 | assert_eq!(text(&server.visible).2, text(&local).2); | ||
| 3175 | let bold = |bytes: &[u8]| { | ||
| 3176 | let store = Store::parse(bytes).unwrap(); | ||
| 3177 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3178 | let document = Document::parse(&index).unwrap(); | ||
| 3179 | let space = &document.spaces[&space]; | ||
| 3180 | space.revisions[&space.contexts[&ExGuid::default()]] | ||
| 3181 | .text_runs(object) | ||
| 3182 | .unwrap() | ||
| 3183 | .into_iter() | ||
| 3184 | .find(|run| !run.text.is_empty()) | ||
| 3185 | .unwrap() | ||
| 3186 | .format | ||
| 3187 | .bold | ||
| 3188 | }; | ||
| 3189 | assert_eq!(bold(&server.visible), Some(true)); | ||
| 3190 | assert_ne!(bold(&server.visible), bold(&local)); | ||
| 3191 | drop(cache); | ||
| 3192 | let cache = Replica::open(&path).unwrap(); | ||
| 3193 | assert_eq!( | ||
| 3194 | cache.sync_once(&mut server).unwrap(), | ||
| 3195 | attempt.map(|state| (id, state)) | ||
| 3196 | ); | ||
| 3197 | assert_eq!(server.publications, 1); | ||
| 3198 | assert_eq!(server.confirmations, 0); | ||
| 3199 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3200 | } | ||
| 3201 | |||
| 3202 | #[test] | ||
| 3203 | fn uncertain_formatting_keeps_the_original_attempt_and_never_replays() { | ||
| 3204 | use onestore::TextAttribute as A; | ||
| 3205 | for fault in [Fault::UnknownBefore, Fault::UnknownAfter] { | ||
| 3206 | let directory = tempfile::tempdir().unwrap(); | ||
| 3207 | let path = directory.path().join("unknown-format.sqlite"); | ||
| 3208 | let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); | ||
| 3209 | let (sid, id, _) = text(&source); | ||
| 3210 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3211 | let local_id = cache | ||
| 3212 | .format(&source, sid, id, 1..5, &[A::Bold(true)]) | ||
| 3213 | .unwrap() | ||
| 3214 | .unwrap(); | ||
| 3215 | let mut server = Server::new(&source); | ||
| 3216 | server.fault = fault; | ||
| 3217 | assert!( | ||
| 3218 | matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown) | ||
| 3219 | ); | ||
| 3220 | let status = cache.status(local_id).unwrap(); | ||
| 3221 | drop(cache); | ||
| 3222 | let cache = Replica::open(&path).unwrap(); | ||
| 3223 | if matches!(fault, Fault::UnknownBefore) { | ||
| 3224 | for _ in 0..4 { | ||
| 3225 | assert_eq!( | ||
| 3226 | cache.sync_once(&mut server).unwrap(), | ||
| 3227 | status.map(|s| (local_id, s)) | ||
| 3228 | ); | ||
| 3229 | } | ||
| 3230 | assert_eq!(server.confirmations, 0); | ||
| 3231 | } else { | ||
| 3232 | assert!(matches!( | ||
| 3233 | cache.sync_once(&mut server).unwrap(), | ||
| 3234 | Some((_, EditStatus::Published { .. })) | ||
| 3235 | )); | ||
| 3236 | assert_eq!(server.confirmations, 1); | ||
| 3237 | } | ||
| 3238 | assert_eq!(server.publications, 1); | ||
| 3239 | } | ||
| 3240 | } | ||
| 3241 | |||
| 3242 | #[test] | ||
| 3243 | fn reviewed_format_conflicts_preserve_dependent_edits_and_recheck_later_remote_changes() { | ||
| 3244 | use onestore::TextAttribute as A; | ||
| 3245 | for changed_again in [false, true] { | ||
| 3246 | let directory = tempfile::tempdir().unwrap(); | ||
| 3247 | let path = directory.path().join("review-format.sqlite"); | ||
| 3248 | let source = onestore::create_section("format.one", "abcdef", "Author").unwrap(); | ||
| 3249 | let (sid, id, _) = text(&source); | ||
| 3250 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3251 | let first = cache | ||
| 3252 | .format(&source, sid, id, 1..5, &[A::FontSize(14.0)]) | ||
| 3253 | .unwrap() | ||
| 3254 | .unwrap(); | ||
| 3255 | let second = cache | ||
| 3256 | .edit_text(&cache.snapshot().unwrap(), sid, id, 2..2, "X") | ||
| 3257 | .unwrap() | ||
| 3258 | .unwrap(); | ||
| 3259 | let local = cache.snapshot().unwrap(); | ||
| 3260 | let pending = cache.pending().unwrap(); | ||
| 3261 | let remote = PreparedEdit::format(&source, sid, id, 1..5, &[A::FontSize(18.0)]).unwrap(); | ||
| 3262 | let mut server = Server::new(remote.as_bytes()); | ||
| 3263 | assert_eq!( | ||
| 3264 | cache.sync_once(&mut server).unwrap(), | ||
| 3265 | Some((first, EditStatus::Conflict(ConflictKind::FormattingChanged))) | ||
| 3266 | ); | ||
| 3267 | assert!( | ||
| 3268 | matches!(cache.rebase_conflict(first,&source,remote.as_bytes(),1..5),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy) | ||
| 3269 | ); | ||
| 3270 | cache | ||
| 3271 | .rebase_conflict(first, &local, remote.as_bytes(), 1..5) | ||
| 3272 | .unwrap(); | ||
| 3273 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3274 | assert_eq!(cache.pending().unwrap()[1], pending[1]); | ||
| 3275 | drop(cache); | ||
| 3276 | let cache = Replica::open(&path).unwrap(); | ||
| 3277 | if changed_again { | ||
| 3278 | let newer = | ||
| 3279 | PreparedEdit::format(&server.visible, sid, id, 1..5, &[A::FontSize(20.0)]).unwrap(); | ||
| 3280 | server = Server::new(newer.as_bytes()); | ||
| 3281 | assert_eq!( | ||
| 3282 | cache.sync_once(&mut server).unwrap(), | ||
| 3283 | Some((first, EditStatus::Conflict(ConflictKind::FormattingChanged))) | ||
| 3284 | ); | ||
| 3285 | assert_eq!(server.publications, 0); | ||
| 3286 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3287 | } else { | ||
| 3288 | for expected in [first, second] { | ||
| 3289 | assert!( | ||
| 3290 | matches!(cache.sync_once(&mut server).unwrap(),Some((id,EditStatus::Published{..}))if id==expected) | ||
| 3291 | ); | ||
| 3292 | } | ||
| 3293 | assert_eq!(text(&server.durable).2, "abXcdef"); | ||
| 3294 | assert!(cache.pending().unwrap().is_empty()); | ||
| 3295 | } | ||
| 3296 | } | ||
| 3297 | } | ||
| 3298 | |||
| 3299 | #[test] | ||
| 3300 | fn superscript_reconciliation_checks_the_implicit_subscript_change() { | ||
| 3301 | use onestore::TextAttribute as A; | ||
| 3302 | let directory = tempfile::tempdir().unwrap(); | ||
| 3303 | let source = onestore::create_section("script.one", "abc", "Author").unwrap(); | ||
| 3304 | let (sid, id, _) = text(&source); | ||
| 3305 | let cache = Replica::create(directory.path().join("script.sqlite"), &source).unwrap(); | ||
| 3306 | let local = cache | ||
| 3307 | .format(&source, sid, id, 0..3, &[A::Superscript(true)]) | ||
| 3308 | .unwrap() | ||
| 3309 | .unwrap(); | ||
| 3310 | let remote = PreparedEdit::format(&source, sid, id, 0..3, &[A::Subscript(true)]).unwrap(); | ||
| 3311 | let mut server = Server::new(remote.as_bytes()); | ||
| 3312 | assert_eq!( | ||
| 3313 | cache.sync_once(&mut server).unwrap(), | ||
| 3314 | Some((local, EditStatus::Conflict(ConflictKind::FormattingChanged))) | ||
| 3315 | ); | ||
| 3316 | assert_eq!(server.publications, 0); | ||
| 3317 | } | ||
| 3318 | |||
| 3319 | #[test] | ||
| 3320 | fn seeded_formatting_reconciliation_matches_a_character_model() { | ||
| 3321 | use onestore::TextAttribute as A; | ||
| 3322 | #[derive(Clone, Debug, PartialEq)] | ||
| 3323 | struct Style { | ||
| 3324 | size: f32, | ||
| 3325 | color: u32, | ||
| 3326 | bold: bool, | ||
| 3327 | italic: bool, | ||
| 3328 | } | ||
| 3329 | let mut conflicts = 0; | ||
| 3330 | let mut published = 0; | ||
| 3331 | let mut satisfied_parts = 0; | ||
| 3332 | for seed in 1_u64..=128 { | ||
| 3333 | let mut random = seed; | ||
| 3334 | let mut next = || { | ||
| 3335 | random = random | ||
| 3336 | .wrapping_mul(6364136223846793005) | ||
| 3337 | .wrapping_add(1442695040888963407); | ||
| 3338 | random >> 32 | ||
| 3339 | }; | ||
| 3340 | let directory = tempfile::tempdir().unwrap(); | ||
| 3341 | let path = directory.path().join("model.sqlite"); | ||
| 3342 | let mut source = | ||
| 3343 | onestore::create_section("model.one", "abcdefghijklmnopqrstuvwxyz012345", "Author") | ||
| 3344 | .unwrap(); | ||
| 3345 | let (sid, object, original_text) = text(&source); | ||
| 3346 | let mut baseline = vec![ | ||
| 3347 | Style { | ||
| 3348 | size: 11.0, | ||
| 3349 | color: 0xff000000, | ||
| 3350 | bold: false, | ||
| 3351 | italic: false | ||
| 3352 | }; | ||
| 3353 | 32 | ||
| 3354 | ]; | ||
| 3355 | for _ in 0..6 { | ||
| 3356 | let start = next() as usize % 32; | ||
| 3357 | let end = start + 1 + next() as usize % (32 - start); | ||
| 3358 | let size = [12.0, 14.0, 18.0][next() as usize % 3]; | ||
| 3359 | let color: u32 = [0xabcdef, 0x987654, 0xff000000][next() as usize % 3]; | ||
| 3360 | let bold = next() % 2 == 0; | ||
| 3361 | source = PreparedEdit::format( | ||
| 3362 | &source, | ||
| 3363 | sid, | ||
| 3364 | object, | ||
| 3365 | start as u32..end as u32, | ||
| 3366 | &[ | ||
| 3367 | A::FontSize(size), | ||
| 3368 | A::Color((color != 0xff000000).then(|| { | ||
| 3369 | let b = color.to_le_bytes(); | ||
| 3370 | [b[0], b[1], b[2]] | ||
| 3371 | })), | ||
| 3372 | A::Bold(bold), | ||
| 3373 | ], | ||
| 3374 | ) | ||
| 3375 | .unwrap() | ||
| 3376 | .as_bytes() | ||
| 3377 | .to_vec(); | ||
| 3378 | for style in &mut baseline[start..end] { | ||
| 3379 | style.size = size; | ||
| 3380 | style.color = color; | ||
| 3381 | style.bold = bold; | ||
| 3382 | } | ||
| 3383 | } | ||
| 3384 | let start = next() as usize % 24; | ||
| 3385 | let end = start + 2 + next() as usize % (31 - start); | ||
| 3386 | let attribute = match seed % 3 { | ||
| 3387 | 0 => A::FontSize(21.0), | ||
| 3388 | 1 => A::Color(Some([0x12, 0x34, 0x56])), | ||
| 3389 | _ => A::Bold(!baseline[start].bold), | ||
| 3390 | }; | ||
| 3391 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3392 | let id = cache | ||
| 3393 | .format( | ||
| 3394 | &source, | ||
| 3395 | sid, | ||
| 3396 | object, | ||
| 3397 | start as u32..end as u32, | ||
| 3398 | std::slice::from_ref(&attribute), | ||
| 3399 | ) | ||
| 3400 | .unwrap() | ||
| 3401 | .unwrap(); | ||
| 3402 | let local = cache.snapshot().unwrap(); | ||
| 3403 | let mut remote = source.clone(); | ||
| 3404 | let mut expected = baseline.clone(); | ||
| 3405 | for step in 0..8 { | ||
| 3406 | let left = next() as usize % 32; | ||
| 3407 | let right = left + 1 + next() as usize % (32 - left); | ||
| 3408 | let update = if step % 3 == 0 { | ||
| 3409 | attribute.clone() | ||
| 3410 | } else { | ||
| 3411 | match next() % 4 { | ||
| 3412 | 0 => A::FontSize([11.0, 14.0, 18.0, 21.0][next() as usize % 4]), | ||
| 3413 | 1 => A::Color(Some([0x99, 0x88, 0x77])), | ||
| 3414 | 2 => A::Bold(next() % 2 == 0), | ||
| 3415 | _ => A::Italic(true), | ||
| 3416 | } | ||
| 3417 | }; | ||
| 3418 | remote = PreparedEdit::format( | ||
| 3419 | &remote, | ||
| 3420 | sid, | ||
| 3421 | object, | ||
| 3422 | left as u32..right as u32, | ||
| 3423 | std::slice::from_ref(&update), | ||
| 3424 | ) | ||
| 3425 | .unwrap() | ||
| 3426 | .as_bytes() | ||
| 3427 | .to_vec(); | ||
| 3428 | for style in &mut expected[left..right] { | ||
| 3429 | match update { | ||
| 3430 | A::FontSize(value) => style.size = value, | ||
| 3431 | A::Color(Some([r, g, b])) => style.color = u32::from_le_bytes([r, g, b, 0]), | ||
| 3432 | A::Bold(value) => style.bold = value, | ||
| 3433 | A::Italic(value) => style.italic = value, | ||
| 3434 | _ => unreachable!(), | ||
| 3435 | } | ||
| 3436 | } | ||
| 3437 | } | ||
| 3438 | let conflict = (start..end).any(|i| match attribute { | ||
| 3439 | A::FontSize(value) => expected[i].size != baseline[i].size && expected[i].size != value, | ||
| 3440 | A::Color(_) => expected[i].color != baseline[i].color && expected[i].color != 0x563412, | ||
| 3441 | A::Bold(value) => expected[i].bold != baseline[i].bold && expected[i].bold != value, | ||
| 3442 | _ => unreachable!(), | ||
| 3443 | }); | ||
| 3444 | drop(cache); | ||
| 3445 | let cache = Replica::open(&path).unwrap(); | ||
| 3446 | let mut server = Server::new(&remote); | ||
| 3447 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 3448 | assert_eq!(result.0, id, "seed {seed}"); | ||
| 3449 | if conflict { | ||
| 3450 | conflicts += 1; | ||
| 3451 | assert_eq!( | ||
| 3452 | result.1, | ||
| 3453 | EditStatus::Conflict(ConflictKind::FormattingChanged), | ||
| 3454 | "seed {seed}" | ||
| 3455 | ); | ||
| 3456 | assert_eq!(server.publications, 0, "seed {seed}"); | ||
| 3457 | assert_eq!(cache.snapshot().unwrap(), local, "seed {seed}"); | ||
| 3458 | continue; | ||
| 3459 | } | ||
| 3460 | published += 1; | ||
| 3461 | assert!( | ||
| 3462 | matches!(result.1, EditStatus::Published { .. }), | ||
| 3463 | "seed {seed}: {result:?}" | ||
| 3464 | ); | ||
| 3465 | for style in &mut expected[start..end] { | ||
| 3466 | match attribute { | ||
| 3467 | A::FontSize(value) => { | ||
| 3468 | satisfied_parts += usize::from(style.size == value); | ||
| 3469 | style.size = value; | ||
| 3470 | } | ||
| 3471 | A::Color(_) => { | ||
| 3472 | satisfied_parts += usize::from(style.color == 0x563412); | ||
| 3473 | style.color = 0x563412; | ||
| 3474 | } | ||
| 3475 | A::Bold(value) => { | ||
| 3476 | satisfied_parts += usize::from(style.bold == value); | ||
| 3477 | style.bold = value; | ||
| 3478 | } | ||
| 3479 | _ => unreachable!(), | ||
| 3480 | } | ||
| 3481 | } | ||
| 3482 | assert_eq!(text(&server.durable).2, original_text, "seed {seed}"); | ||
| 3483 | let store = Store::parse(&server.durable).unwrap(); | ||
| 3484 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3485 | let doc = Document::parse(&index).unwrap(); | ||
| 3486 | let space = &doc.spaces[&sid]; | ||
| 3487 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 3488 | let actual: Vec<_> = revision | ||
| 3489 | .text_runs(object) | ||
| 3490 | .unwrap() | ||
| 3491 | .iter() | ||
| 3492 | .flat_map(|run| { | ||
| 3493 | std::iter::repeat_n( | ||
| 3494 | Style { | ||
| 3495 | size: run.format.font_size.unwrap(), | ||
| 3496 | color: run.format.color.unwrap_or(0xff000000), | ||
| 3497 | bold: run.format.bold.unwrap_or(false), | ||
| 3498 | italic: run.format.italic.unwrap_or(false), | ||
| 3499 | }, | ||
| 3500 | run.text.chars().count(), | ||
| 3501 | ) | ||
| 3502 | }) | ||
| 3503 | .collect(); | ||
| 3504 | assert_eq!(actual, expected, "seed {seed}"); | ||
| 3505 | drop(cache); | ||
| 3506 | let cache = Replica::open(&path).unwrap(); | ||
| 3507 | assert_eq!(cache.status(id).unwrap(), Some(result.1)); | ||
| 3508 | let snapshot = cache.snapshot().unwrap(); | ||
| 3509 | assert_eq!(snapshot.len(), server.durable.len(), "seed {seed}"); | ||
| 3510 | assert!( | ||
| 3511 | snapshot | ||
| 3512 | .iter() | ||
| 3513 | .zip(&server.durable) | ||
| 3514 | .enumerate() | ||
| 3515 | .all(|(offset, (a, b))| a == b || (212..252).contains(&offset)), | ||
| 3516 | "seed {seed}: only confirmation version metadata may change" | ||
| 3517 | ); | ||
| 3518 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | ||
| 3519 | assert!( | ||
| 3520 | cache.snapshot().unwrap() == server.durable, | ||
| 3521 | "seed {seed}: refreshed snapshot" | ||
| 3522 | ); | ||
| 3523 | } | ||
| 3524 | assert!( | ||
| 3525 | conflicts >= 16 && published >= 32 && satisfied_parts >= 128, | ||
| 3526 | "conflicts={conflicts}, published={published}, already desired characters={satisfied_parts}" | ||
| 3527 | ); | ||
| 3528 | println!( | ||
| 3529 | "128 seeds: {conflicts} conflicts, {published} publications, {satisfied_parts} already desired characters" | ||
| 3530 | ); | ||
| 3531 | } | ||
| 3532 | |||
| 3533 | #[test] | ||
| 3534 | fn inserted_empty_text_retains_formatting_and_dependent_text_across_sync() { | ||
| 3535 | use onestore::{Insertion, TextAttribute as A}; | ||
| 3536 | let directory = tempfile::tempdir().unwrap(); | ||
| 3537 | let path = directory.path().join("empty.sqlite"); | ||
| 3538 | let source = onestore::create_section("empty.one", "Original", "Author").unwrap(); | ||
| 3539 | let store = Store::parse(&source).unwrap(); | ||
| 3540 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3541 | let doc = Document::parse(&index).unwrap(); | ||
| 3542 | let (sid, page) = doc.pages().unwrap()[0]; | ||
| 3543 | let insertion = Insertion::outline(page, 144.0, 144.0, "", "Author").unwrap(); | ||
| 3544 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3545 | let first = cache.insert(&source, sid, &insertion).unwrap().unwrap(); | ||
| 3546 | let second = cache | ||
| 3547 | .format( | ||
| 3548 | &cache.snapshot().unwrap(), | ||
| 3549 | sid, | ||
| 3550 | insertion.text_object(), | ||
| 3551 | 0..0, | ||
| 3552 | &[A::Bold(true), A::FontSize(18.0)], | ||
| 3553 | ) | ||
| 3554 | .unwrap() | ||
| 3555 | .unwrap(); | ||
| 3556 | let third = cache | ||
| 3557 | .edit_text( | ||
| 3558 | &cache.snapshot().unwrap(), | ||
| 3559 | sid, | ||
| 3560 | insertion.text_object(), | ||
| 3561 | 0..0, | ||
| 3562 | "Typed 🦀", | ||
| 3563 | ) | ||
| 3564 | .unwrap() | ||
| 3565 | .unwrap(); | ||
| 3566 | let pending = cache.pending().unwrap(); | ||
| 3567 | drop(cache); | ||
| 3568 | let cache = Replica::open(&path).unwrap(); | ||
| 3569 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 3570 | let mut server = Server::new(&source); | ||
| 3571 | for id in [first, second, third] { | ||
| 3572 | let result = cache.sync_once(&mut server).unwrap(); | ||
| 3573 | assert!( | ||
| 3574 | matches!(result,Some((actual,EditStatus::Published{..}))if actual==id), | ||
| 3575 | "{result:?}" | ||
| 3576 | ); | ||
| 3577 | } | ||
| 3578 | let store = Store::parse(&server.durable).unwrap(); | ||
| 3579 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3580 | let doc = Document::parse(&index).unwrap(); | ||
| 3581 | let space = &doc.spaces[&sid]; | ||
| 3582 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 3583 | let runs = revision.text_runs(insertion.text_object()).unwrap(); | ||
| 3584 | assert_eq!(runs.iter().map(|r| r.text).collect::<String>(), "Typed 🦀"); | ||
| 3585 | assert!( | ||
| 3586 | runs.iter() | ||
| 3587 | .all(|r| r.format.bold == Some(true) && r.format.font_size == Some(18.0)) | ||
| 3588 | ); | ||
| 3589 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 3590 | } | ||
| 3591 | |||
| 3592 | #[test] | ||
| 3593 | fn every_visual_attribute_rebases_with_an_independent_remote_attribute() { | ||
| 3594 | use onestore::TextAttribute as A; | ||
| 3595 | use serde_json::json; | ||
| 3596 | let mut cases = Vec::new(); | ||
| 3597 | for value in [false, true] { | ||
| 3598 | cases.extend([ | ||
| 3599 | (A::Bold(!value), A::Bold(value), "bold", json!(value)), | ||
| 3600 | (A::Italic(!value), A::Italic(value), "italic", json!(value)), | ||
| 3601 | ( | ||
| 3602 | A::Underline(!value), | ||
| 3603 | A::Underline(value), | ||
| 3604 | "underline", | ||
| 3605 | json!(value), | ||
| 3606 | ), | ||
| 3607 | (A::Strike(!value), A::Strike(value), "strike", json!(value)), | ||
| 3608 | ( | ||
| 3609 | A::Superscript(!value), | ||
| 3610 | A::Superscript(value), | ||
| 3611 | "superscript", | ||
| 3612 | json!(value), | ||
| 3613 | ), | ||
| 3614 | ( | ||
| 3615 | A::Subscript(!value), | ||
| 3616 | A::Subscript(value), | ||
| 3617 | "subscript", | ||
| 3618 | json!(value), | ||
| 3619 | ), | ||
| 3620 | ]); | ||
| 3621 | } | ||
| 3622 | cases.extend([ | ||
| 3623 | ( | ||
| 3624 | A::Font("Georgia".into()), | ||
| 3625 | A::Font("Arial".into()), | ||
| 3626 | "font", | ||
| 3627 | json!("Arial"), | ||
| 3628 | ), | ||
| 3629 | ( | ||
| 3630 | A::FontSize(11.0), | ||
| 3631 | A::FontSize(18.0), | ||
| 3632 | "font_size", | ||
| 3633 | json!(18.0), | ||
| 3634 | ), | ||
| 3635 | ( | ||
| 3636 | A::Color(None), | ||
| 3637 | A::Color(Some([1, 2, 3])), | ||
| 3638 | "color", | ||
| 3639 | json!(0x030201), | ||
| 3640 | ), | ||
| 3641 | ( | ||
| 3642 | A::Color(Some([1, 2, 3])), | ||
| 3643 | A::Color(None), | ||
| 3644 | "color", | ||
| 3645 | json!(0xff000000_u32), | ||
| 3646 | ), | ||
| 3647 | ( | ||
| 3648 | A::Highlight(None), | ||
| 3649 | A::Highlight(Some([4, 5, 6])), | ||
| 3650 | "highlight", | ||
| 3651 | json!(0x060504), | ||
| 3652 | ), | ||
| 3653 | ( | ||
| 3654 | A::Highlight(Some([4, 5, 6])), | ||
| 3655 | A::Highlight(None), | ||
| 3656 | "highlight", | ||
| 3657 | json!(0xff000000_u32), | ||
| 3658 | ), | ||
| 3659 | ]); | ||
| 3660 | for (baseline, desired, field, value) in cases { | ||
| 3661 | let directory = tempfile::tempdir().unwrap(); | ||
| 3662 | let path = directory.path().join("attribute.sqlite"); | ||
| 3663 | let source = onestore::create_section("attribute.one", "abc", "Author").unwrap(); | ||
| 3664 | let (sid, object, _) = text(&source); | ||
| 3665 | let source = PreparedEdit::format(&source, sid, object, 0..3, &[baseline]) | ||
| 3666 | .unwrap() | ||
| 3667 | .as_bytes() | ||
| 3668 | .to_vec(); | ||
| 3669 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3670 | let id = cache | ||
| 3671 | .format(&source, sid, object, 0..3, &[desired]) | ||
| 3672 | .unwrap() | ||
| 3673 | .unwrap(); | ||
| 3674 | let moved = PreparedEdit::text(&source, sid, object, 0..0, "Z").unwrap(); | ||
| 3675 | let (other, other_field, other_value) = if field == "font_size" { | ||
| 3676 | (A::Italic(true), "italic", json!(true)) | ||
| 3677 | } else { | ||
| 3678 | (A::FontSize(22.0), "font_size", json!(22.0)) | ||
| 3679 | }; | ||
| 3680 | let remote = PreparedEdit::format(moved.as_bytes(), sid, object, 1..4, &[other]).unwrap(); | ||
| 3681 | let mut server = Server::new(remote.as_bytes()); | ||
| 3682 | drop(cache); | ||
| 3683 | let cache = Replica::open(&path).unwrap(); | ||
| 3684 | let result = cache.sync_once(&mut server).unwrap(); | ||
| 3685 | assert!( | ||
| 3686 | matches!(result,Some((observed,EditStatus::Published{..}))if observed==id), | ||
| 3687 | "{field}={value}: {result:?}" | ||
| 3688 | ); | ||
| 3689 | assert_eq!(text(&server.durable).2, "Zabc"); | ||
| 3690 | let store = Store::parse(&server.durable).unwrap(); | ||
| 3691 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3692 | let doc = Document::parse(&index).unwrap(); | ||
| 3693 | let space = &doc.spaces[&sid]; | ||
| 3694 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 3695 | let mut position = 0; | ||
| 3696 | for run in revision.text_runs(object).unwrap() { | ||
| 3697 | let format = serde_json::to_value(run.format).unwrap(); | ||
| 3698 | for _ in run.text.chars() { | ||
| 3699 | if position > 0 { | ||
| 3700 | assert_eq!(format[field], value, "{field}, character {position}"); | ||
| 3701 | assert_eq!( | ||
| 3702 | format[other_field], other_value, | ||
| 3703 | "{field}, character {position}" | ||
| 3704 | ); | ||
| 3705 | } | ||
| 3706 | position += 1; | ||
| 3707 | } | ||
| 3708 | } | ||
| 3709 | assert_eq!(position, 4); | ||
| 3710 | } | ||
| 3711 | } | ||
| 3712 | |||
| 3713 | #[test] | ||
| 3714 | fn reviewed_insertion_placements_preserve_identity_and_dependent_operations() { | ||
| 3715 | use onestore::{Insertion, TextAttribute as A}; | ||
| 3716 | use onestore_offline::Operation; | ||
| 3717 | for outline_case in [false, true] { | ||
| 3718 | let directory = tempfile::tempdir().unwrap(); | ||
| 3719 | let path = directory.path().join("placement.sqlite"); | ||
| 3720 | let base = onestore::create_section("placement.one", "Original", "Author").unwrap(); | ||
| 3721 | let (sid, _, _) = text(&base); | ||
| 3722 | let store = Store::parse(&base).unwrap(); | ||
| 3723 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3724 | let doc = Document::parse(&index).unwrap(); | ||
| 3725 | let (_, page) = doc.pages().unwrap()[0]; | ||
| 3726 | let space = &doc.spaces[&sid]; | ||
| 3727 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 3728 | let parent = *view.nodes[&page] | ||
| 3729 | .children | ||
| 3730 | .iter() | ||
| 3731 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 3732 | .unwrap(); | ||
| 3733 | let anchor = Insertion::paragraph(parent, None, "Temporary anchor", "Author").unwrap(); | ||
| 3734 | let source = PreparedEdit::insert(&base, sid, &anchor) | ||
| 3735 | .unwrap() | ||
| 3736 | .as_bytes() | ||
| 3737 | .to_vec(); | ||
| 3738 | let insertion = if outline_case { | ||
| 3739 | Insertion::outline(page, 144.0, 144.0, "Offline", "Author").unwrap() | ||
| 3740 | } else { | ||
| 3741 | Insertion::paragraph(parent, Some(anchor.object()), "Offline", "Author").unwrap() | ||
| 3742 | } | ||
| 3743 | .with_formatting(0..7, &[A::Italic(true)]) | ||
| 3744 | .unwrap(); | ||
| 3745 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3746 | let first = cache.insert(&source, sid, &insertion).unwrap().unwrap(); | ||
| 3747 | let second = cache | ||
| 3748 | .format( | ||
| 3749 | &cache.snapshot().unwrap(), | ||
| 3750 | sid, | ||
| 3751 | insertion.text_object(), | ||
| 3752 | 0..7, | ||
| 3753 | &[A::Bold(true)], | ||
| 3754 | ) | ||
| 3755 | .unwrap() | ||
| 3756 | .unwrap(); | ||
| 3757 | let third = cache | ||
| 3758 | .edit_text( | ||
| 3759 | &cache.snapshot().unwrap(), | ||
| 3760 | sid, | ||
| 3761 | insertion.text_object(), | ||
| 3762 | 7..7, | ||
| 3763 | " 🦀", | ||
| 3764 | ) | ||
| 3765 | .unwrap() | ||
| 3766 | .unwrap(); | ||
| 3767 | let local = cache.snapshot().unwrap(); | ||
| 3768 | let pending = cache.pending().unwrap(); | ||
| 3769 | let mut server = Server::new(&base); | ||
| 3770 | if outline_case { | ||
| 3771 | drop(cache); | ||
| 3772 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 3773 | db.execute( | ||
| 3774 | "INSERT INTO conflicts VALUES (?1,2)", | ||
| 3775 | [i64::try_from(first).unwrap()], | ||
| 3776 | ) | ||
| 3777 | .unwrap(); | ||
| 3778 | db.execute("UPDATE replica SET base=?1", [&base]).unwrap(); | ||
| 3779 | drop(db); | ||
| 3780 | } else { | ||
| 3781 | assert_eq!( | ||
| 3782 | cache.sync_once(&mut server).unwrap(), | ||
| 3783 | Some((first, EditStatus::Conflict(ConflictKind::UnsupportedEdit))) | ||
| 3784 | ); | ||
| 3785 | drop(cache); | ||
| 3786 | } | ||
| 3787 | let cache = Replica::open(&path).unwrap(); | ||
| 3788 | if outline_case { | ||
| 3789 | assert!( | ||
| 3790 | cache | ||
| 3791 | .rebase_paragraph_conflict(first, &local, &base, parent, None) | ||
| 3792 | .is_err() | ||
| 3793 | ); | ||
| 3794 | assert!( | ||
| 3795 | cache | ||
| 3796 | .rebase_outline_conflict(first, &local, &base, page, f32::NAN, 288.0) | ||
| 3797 | .is_err() | ||
| 3798 | ); | ||
| 3799 | assert!( | ||
| 3800 | matches!(cache.rebase_outline_conflict(first,&source,&base,page,288.0,360.0),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy) | ||
| 3801 | ); | ||
| 3802 | cache | ||
| 3803 | .rebase_outline_conflict(first, &local, &base, page, 288.0, 360.0) | ||
| 3804 | .unwrap(); | ||
| 3805 | } else { | ||
| 3806 | assert!( | ||
| 3807 | cache | ||
| 3808 | .rebase_outline_conflict(first, &local, &base, page, 288.0, 360.0) | ||
| 3809 | .is_err() | ||
| 3810 | ); | ||
| 3811 | assert!( | ||
| 3812 | cache | ||
| 3813 | .rebase_paragraph_conflict(first, &local, &base, parent, Some(anchor.object())) | ||
| 3814 | .is_err() | ||
| 3815 | ); | ||
| 3816 | assert!( | ||
| 3817 | matches!(cache.rebase_paragraph_conflict(first,&source,&base,parent,None),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy) | ||
| 3818 | ); | ||
| 3819 | cache | ||
| 3820 | .rebase_paragraph_conflict(first, &local, &base, parent, None) | ||
| 3821 | .unwrap(); | ||
| 3822 | } | ||
| 3823 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3824 | assert_eq!(cache.pending().unwrap()[1..], pending[1..]); | ||
| 3825 | let Operation::Insert(rebased) = cache.pending().unwrap().remove(0).operation else { | ||
| 3826 | panic!() | ||
| 3827 | }; | ||
| 3828 | assert_eq!(rebased.object(), insertion.object()); | ||
| 3829 | assert_eq!(rebased.text_object(), insertion.text_object()); | ||
| 3830 | assert_eq!( | ||
| 3831 | serde_json::to_value(&rebased).unwrap()["formats"], | ||
| 3832 | serde_json::to_value(&insertion).unwrap()["formats"] | ||
| 3833 | ); | ||
| 3834 | assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending)); | ||
| 3835 | drop(cache); | ||
| 3836 | let cache = Replica::open(&path).unwrap(); | ||
| 3837 | for id in [first, second, third] { | ||
| 3838 | let result = cache.sync_once(&mut server).unwrap(); | ||
| 3839 | assert!( | ||
| 3840 | matches!(result,Some((actual,EditStatus::Published{..}))if actual==id), | ||
| 3841 | "{result:?}" | ||
| 3842 | ); | ||
| 3843 | } | ||
| 3844 | let store = Store::parse(&server.durable).unwrap(); | ||
| 3845 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3846 | let doc = Document::parse(&index).unwrap(); | ||
| 3847 | let space = &doc.spaces[&sid]; | ||
| 3848 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 3849 | let runs = view.text_runs(insertion.text_object()).unwrap(); | ||
| 3850 | assert_eq!( | ||
| 3851 | runs.iter().map(|r| r.text).collect::<String>(), | ||
| 3852 | "Offline 🦀" | ||
| 3853 | ); | ||
| 3854 | assert!(runs.iter().all(|r| r.format.bold == Some(true))); | ||
| 3855 | assert!(runs.iter().all(|r| r.format.italic == Some(true))); | ||
| 3856 | if outline_case { | ||
| 3857 | assert_eq!( | ||
| 3858 | ( | ||
| 3859 | view.nodes[&insertion.object()].layout.x, | ||
| 3860 | view.nodes[&insertion.object()].layout.y | ||
| 3861 | ), | ||
| 3862 | (Some(288.0), Some(360.0)) | ||
| 3863 | ); | ||
| 3864 | } else { | ||
| 3865 | assert_eq!( | ||
| 3866 | view.nodes[&parent].children.last(), | ||
| 3867 | Some(&insertion.object()) | ||
| 3868 | ); | ||
| 3869 | } | ||
| 3870 | assert!(!view.nodes.contains_key(&anchor.object())); | ||
| 3871 | assert_eq!(server.publications, 3); | ||
| 3872 | assert!(cache.pending().unwrap().is_empty()); | ||
| 3873 | assert_eq!(cache.snapshot().unwrap(), server.durable); | ||
| 3874 | } | ||
| 3875 | } | ||
| 3876 | |||
| 3877 | #[test] | ||
| 3878 | fn placement_reviews_cannot_replace_pending_or_uncertain_attempts() { | ||
| 3879 | use onestore::Insertion; | ||
| 3880 | for outline_case in [false, true] { | ||
| 3881 | let directory = tempfile::tempdir().unwrap(); | ||
| 3882 | let path = directory.path().join("attempt-placement.sqlite"); | ||
| 3883 | let source = onestore::create_section("placement.one", "Original", "Author").unwrap(); | ||
| 3884 | let store = Store::parse(&source).unwrap(); | ||
| 3885 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 3886 | let doc = Document::parse(&index).unwrap(); | ||
| 3887 | let (sid, page) = doc.pages().unwrap()[0]; | ||
| 3888 | let space = &doc.spaces[&sid]; | ||
| 3889 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 3890 | let parent = *view.nodes[&page] | ||
| 3891 | .children | ||
| 3892 | .iter() | ||
| 3893 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 3894 | .unwrap(); | ||
| 3895 | let insertion = if outline_case { | ||
| 3896 | Insertion::outline(page, 144.0, 144.0, "Offline", "Author").unwrap() | ||
| 3897 | } else { | ||
| 3898 | Insertion::paragraph(parent, None, "Offline", "Author").unwrap() | ||
| 3899 | }; | ||
| 3900 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 3901 | let id = cache.insert(&source, sid, &insertion).unwrap().unwrap(); | ||
| 3902 | let local = cache.snapshot().unwrap(); | ||
| 3903 | let pending = cache.pending().unwrap(); | ||
| 3904 | let mut server = Server::new(&source); | ||
| 3905 | for attempted in [false, true] { | ||
| 3906 | if attempted { | ||
| 3907 | server.fault = Fault::UnknownBefore; | ||
| 3908 | assert!( | ||
| 3909 | matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown) | ||
| 3910 | ); | ||
| 3911 | } | ||
| 3912 | let state = cache.status(id).unwrap(); | ||
| 3913 | let result = if outline_case { | ||
| 3914 | cache.rebase_outline_conflict(id, &local, &source, page, 288.0, 360.0) | ||
| 3915 | } else { | ||
| 3916 | cache.rebase_paragraph_conflict(id, &local, &source, parent, None) | ||
| 3917 | }; | ||
| 3918 | assert!(matches!(result,Err(Error::Io(e))if e.kind()==io::ErrorKind::InvalidInput)); | ||
| 3919 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 3920 | assert_eq!(cache.status(id).unwrap(), state); | ||
| 3921 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 3922 | } | ||
| 3923 | drop(cache); | ||
| 3924 | let cache = Replica::open(&path).unwrap(); | ||
| 3925 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 3926 | assert!(matches!( | ||
| 3927 | cache.sync_once(&mut server).unwrap(), | ||
| 3928 | Some((_, EditStatus::AwaitingConfirmation { .. })) | ||
| 3929 | )); | ||
| 3930 | assert_eq!(server.publications, 1); | ||
| 3931 | } | ||
| 3932 | } | ||
crates/onestore-offline/tests/sync/outline.rs deleted-719| ... | @@ -1,719 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::OutlineEdit as Change; | ||
| 3 | |||
| 4 | pub(super) fn fixture() -> (Vec<u8>, ExGuid, ExGuid, ExGuid, ExGuid) { | ||
| 5 | let source = onestore::create_section("layout.one", "Original 🦀 é", "Author").unwrap(); | ||
| 6 | let (sid, text, _) = text(&source); | ||
| 7 | let store = Store::parse(&source).unwrap(); | ||
| 8 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 9 | let document = Document::parse(&index).unwrap(); | ||
| 10 | let space = &document.spaces[&sid]; | ||
| 11 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 12 | let paragraph = *view | ||
| 13 | .nodes | ||
| 14 | .iter() | ||
| 15 | .find(|(_, node)| node.content == [text]) | ||
| 16 | .unwrap() | ||
| 17 | .0; | ||
| 18 | let outline = *view | ||
| 19 | .nodes | ||
| 20 | .iter() | ||
| 21 | .find(|(_, node)| node.children.contains(&paragraph)) | ||
| 22 | .unwrap() | ||
| 23 | .0; | ||
| 24 | (source, sid, outline, paragraph, text) | ||
| 25 | } | ||
| 26 | |||
| 27 | pub(super) fn node(source: &[u8], sid: ExGuid, object: ExGuid) -> serde_json::Value { | ||
| 28 | let store = Store::parse(source).unwrap(); | ||
| 29 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 30 | index.validate_current().unwrap(); | ||
| 31 | let document = Document::parse(&index).unwrap(); | ||
| 32 | let space = &document.spaces[&sid]; | ||
| 33 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 34 | serde_json::to_value(&view.nodes[&object]).unwrap() | ||
| 35 | } | ||
| 36 | |||
| 37 | #[test] | ||
| 38 | fn layout_and_dependent_text_survive_reopen_and_independent_remote_formatting() { | ||
| 39 | let (source, sid, outline, paragraph, text_id) = fixture(); | ||
| 40 | for (object, change) in [ | ||
| 41 | (outline, Change::Position { x: 180.0, y: 216.0 }), | ||
| 42 | ( | ||
| 43 | outline, | ||
| 44 | Change::Width { | ||
| 45 | points: 144.0, | ||
| 46 | user_set: true, | ||
| 47 | }, | ||
| 48 | ), | ||
| 49 | (paragraph, Change::Collapsed(true)), | ||
| 50 | ] { | ||
| 51 | let directory = tempfile::tempdir().unwrap(); | ||
| 52 | let path = directory.path().join("cache.sqlite"); | ||
| 53 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 54 | let id = cache | ||
| 55 | .outline(&source, sid, object, change) | ||
| 56 | .unwrap() | ||
| 57 | .unwrap(); | ||
| 58 | let local = cache.snapshot().unwrap(); | ||
| 59 | let expected = node(&local, sid, object); | ||
| 60 | let dependent = cache | ||
| 61 | .edit_text(&local, sid, text_id, 0..0, "Local ") | ||
| 62 | .unwrap() | ||
| 63 | .unwrap(); | ||
| 64 | let pending = cache.pending().unwrap(); | ||
| 65 | let local = cache.snapshot().unwrap(); | ||
| 66 | drop(cache); | ||
| 67 | let cache = Replica::open(&path).unwrap(); | ||
| 68 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 69 | assert!(cache.snapshot().unwrap() == local); | ||
| 70 | let remote = PreparedEdit::format( | ||
| 71 | &source, | ||
| 72 | sid, | ||
| 73 | text_id, | ||
| 74 | 0..8, | ||
| 75 | &[onestore::TextAttribute::Bold(true)], | ||
| 76 | ) | ||
| 77 | .unwrap(); | ||
| 78 | let mut server = Server::new(remote.as_bytes()); | ||
| 79 | assert!( | ||
| 80 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) | ||
| 81 | ); | ||
| 82 | let actual = node(&server.durable, sid, object); | ||
| 83 | assert_eq!(actual["layout"], expected["layout"]); | ||
| 84 | assert_eq!(actual["kind"], expected["kind"]); | ||
| 85 | assert_eq!(cache.pending().unwrap().len(), 1); | ||
| 86 | drop(cache); | ||
| 87 | let cache = Replica::open(&path).unwrap(); | ||
| 88 | assert!( | ||
| 89 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==dependent) | ||
| 90 | ); | ||
| 91 | assert_eq!(text(&server.durable).2, "Local Original 🦀 é"); | ||
| 92 | assert!(cache.pending().unwrap().is_empty()); | ||
| 93 | let store = Store::parse(&server.durable).unwrap(); | ||
| 94 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 95 | let document = Document::parse(&index).unwrap(); | ||
| 96 | let space = &document.spaces[&sid]; | ||
| 97 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 98 | assert!(view.text_runs(text_id).unwrap()[0].format.bold.unwrap()); | ||
| 99 | assert_eq!(server.publications, 2); | ||
| 100 | drop(cache); | ||
| 101 | let cache = Replica::open(&path).unwrap(); | ||
| 102 | for id in [id, dependent] { | ||
| 103 | assert!(matches!( | ||
| 104 | cache.status(id).unwrap(), | ||
| 105 | Some(EditStatus::Published { .. }) | ||
| 106 | )); | ||
| 107 | } | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | #[test] | ||
| 112 | fn rebased_outline_movement_uses_remote_title_text_and_confirms_after_reopen() { | ||
| 113 | let (source, sid, outline, _, text_id) = fixture(); | ||
| 114 | let store = Store::parse(&source).unwrap(); | ||
| 115 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 116 | let document = Document::parse(&index).unwrap(); | ||
| 117 | let (_, page) = document.pages().unwrap()[0]; | ||
| 118 | let space = &document.spaces[&sid]; | ||
| 119 | let metadata = space.revisions[&space.contexts[&ExGuid::default()]].roots[&2]; | ||
| 120 | let second = onestore::Insertion::outline(page, 144.0, 36.0, "Second", "Author").unwrap(); | ||
| 121 | let source = PreparedEdit::insert(&source, sid, &second) | ||
| 122 | .unwrap() | ||
| 123 | .as_bytes() | ||
| 124 | .to_vec(); | ||
| 125 | let directory = tempfile::tempdir().unwrap(); | ||
| 126 | let path = directory.path().join("titles.sqlite"); | ||
| 127 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 128 | let movement = cache | ||
| 129 | .outline( | ||
| 130 | &source, | ||
| 131 | sid, | ||
| 132 | outline, | ||
| 133 | Change::Position { x: 216.0, y: 72.0 }, | ||
| 134 | ) | ||
| 135 | .unwrap() | ||
| 136 | .unwrap(); | ||
| 137 | let local = cache.snapshot().unwrap(); | ||
| 138 | assert_eq!(node(&local, sid, metadata)["kind"]["title"], "Second"); | ||
| 139 | let dependent = cache | ||
| 140 | .edit_text(&local, sid, text_id, 0..0, "Local ") | ||
| 141 | .unwrap() | ||
| 142 | .unwrap(); | ||
| 143 | drop(cache); | ||
| 144 | let remote = | ||
| 145 | PreparedEdit::text(&source, sid, second.text_object(), 0..6, "Remote second 🐈").unwrap(); | ||
| 146 | let mut server = Server::new(remote.as_bytes()); | ||
| 147 | server.fault = Fault::UnknownAfter; | ||
| 148 | let cache = Replica::open(&path).unwrap(); | ||
| 149 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 150 | assert!(matches!( | ||
| 151 | cache.status(movement).unwrap(), | ||
| 152 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 153 | )); | ||
| 154 | assert_eq!( | ||
| 155 | node(&server.durable, sid, metadata)["kind"]["title"], | ||
| 156 | "Original 🦀 é" | ||
| 157 | ); | ||
| 158 | assert_eq!( | ||
| 159 | node(&server.visible, sid, metadata)["kind"]["title"], | ||
| 160 | "Remote second 🐈" | ||
| 161 | ); | ||
| 162 | drop(cache); | ||
| 163 | for id in [movement, dependent] { | ||
| 164 | let cache = Replica::open(&path).unwrap(); | ||
| 165 | assert!( | ||
| 166 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) | ||
| 167 | ); | ||
| 168 | assert_eq!( | ||
| 169 | node(&server.durable, sid, metadata)["kind"]["title"], | ||
| 170 | "Remote second 🐈" | ||
| 171 | ); | ||
| 172 | assert_eq!( | ||
| 173 | node(&server.durable, sid, page)["kind"]["alternate_title"], | ||
| 174 | "Remote second 🐈" | ||
| 175 | ); | ||
| 176 | } | ||
| 177 | let cache = Replica::open(&path).unwrap(); | ||
| 178 | assert!(cache.pending().unwrap().is_empty()); | ||
| 179 | assert_eq!( | ||
| 180 | node(&cache.snapshot().unwrap(), sid, text_id)["kind"]["text"], | ||
| 181 | "Local Original 🦀 é" | ||
| 182 | ); | ||
| 183 | assert_eq!(server.publications, 2); | ||
| 184 | } | ||
| 185 | |||
| 186 | #[test] | ||
| 187 | fn competing_layout_requires_current_review_and_preserves_dependent_edits() { | ||
| 188 | let (source, sid, outline, _, text_id) = fixture(); | ||
| 189 | for (local_change, remote_change) in [ | ||
| 190 | ( | ||
| 191 | Change::Position { x: 180.0, y: 216.0 }, | ||
| 192 | Change::Position { x: 288.0, y: 216.0 }, | ||
| 193 | ), | ||
| 194 | ( | ||
| 195 | Change::Width { | ||
| 196 | points: 144.0, | ||
| 197 | user_set: true, | ||
| 198 | }, | ||
| 199 | Change::Width { | ||
| 200 | points: 216.0, | ||
| 201 | user_set: false, | ||
| 202 | }, | ||
| 203 | ), | ||
| 204 | ] { | ||
| 205 | let directory = tempfile::tempdir().unwrap(); | ||
| 206 | let path = directory.path().join("cache.sqlite"); | ||
| 207 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 208 | let id = cache | ||
| 209 | .outline(&source, sid, outline, local_change) | ||
| 210 | .unwrap() | ||
| 211 | .unwrap(); | ||
| 212 | let dependent = cache | ||
| 213 | .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ") | ||
| 214 | .unwrap() | ||
| 215 | .unwrap(); | ||
| 216 | let local = cache.snapshot().unwrap(); | ||
| 217 | let queue = cache.pending().unwrap(); | ||
| 218 | let remote = PreparedEdit::outline(&source, sid, outline, remote_change).unwrap(); | ||
| 219 | let mut server = Server::new(remote.as_bytes()); | ||
| 220 | assert_eq!( | ||
| 221 | cache.sync_once(&mut server).unwrap(), | ||
| 222 | Some((id, EditStatus::Conflict(ConflictKind::LayoutChanged))) | ||
| 223 | ); | ||
| 224 | assert_eq!(server.publications, 0); | ||
| 225 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 226 | assert!(cache.snapshot().unwrap() == local); | ||
| 227 | assert!( | ||
| 228 | cache | ||
| 229 | .rebase_layout_conflict(id, &source, &server.visible) | ||
| 230 | .is_err() | ||
| 231 | ); | ||
| 232 | assert!(cache.rebase_layout_conflict(id, &local, &source).is_err()); | ||
| 233 | assert!( | ||
| 234 | cache | ||
| 235 | .rebase_conflict(id, &local, &server.visible, 0..0) | ||
| 236 | .is_err() | ||
| 237 | ); | ||
| 238 | drop(cache); | ||
| 239 | let cache = Replica::open(&path).unwrap(); | ||
| 240 | assert_eq!( | ||
| 241 | cache.status(id).unwrap(), | ||
| 242 | Some(EditStatus::Conflict(ConflictKind::LayoutChanged)) | ||
| 243 | ); | ||
| 244 | cache | ||
| 245 | .rebase_layout_conflict(id, &local, &server.visible) | ||
| 246 | .unwrap(); | ||
| 247 | let reviewed = cache.pending().unwrap(); | ||
| 248 | assert_eq!(reviewed[1], queue[1]); | ||
| 249 | assert!(cache.snapshot().unwrap() == local); | ||
| 250 | drop(cache); | ||
| 251 | let cache = Replica::open(&path).unwrap(); | ||
| 252 | assert_eq!(cache.pending().unwrap(), reviewed); | ||
| 253 | for id in [id, dependent] { | ||
| 254 | assert!( | ||
| 255 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | ||
| 256 | ); | ||
| 257 | } | ||
| 258 | assert_eq!( | ||
| 259 | node(&server.durable, sid, outline)["layout"], | ||
| 260 | node(&local, sid, outline)["layout"] | ||
| 261 | ); | ||
| 262 | assert_eq!(text(&server.durable).2, "Local Original 🦀 é"); | ||
| 263 | } | ||
| 264 | } | ||
| 265 | |||
| 266 | #[test] | ||
| 267 | fn independent_axes_converge_without_overwriting_competing_values() { | ||
| 268 | let (source, sid, outline, _, _) = fixture(); | ||
| 269 | let directory = tempfile::tempdir().unwrap(); | ||
| 270 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); | ||
| 271 | let original = node(&source, sid, outline); | ||
| 272 | let local = Change::Position { x: 180.0, y: 216.0 }; | ||
| 273 | let id = cache | ||
| 274 | .outline(&source, sid, outline, local) | ||
| 275 | .unwrap() | ||
| 276 | .unwrap(); | ||
| 277 | let remote = PreparedEdit::outline( | ||
| 278 | &source, | ||
| 279 | sid, | ||
| 280 | outline, | ||
| 281 | Change::Position { | ||
| 282 | x: original["layout"]["x"].as_f64().unwrap() as f32, | ||
| 283 | y: 216.0, | ||
| 284 | }, | ||
| 285 | ) | ||
| 286 | .unwrap(); | ||
| 287 | let mut server = Server::new(remote.as_bytes()); | ||
| 288 | assert!( | ||
| 289 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) | ||
| 290 | ); | ||
| 291 | let actual = node(&server.durable, sid, outline); | ||
| 292 | assert_eq!(actual["layout"]["x"], 180.0); | ||
| 293 | assert_eq!(actual["layout"]["y"], 216.0); | ||
| 294 | } | ||
| 295 | |||
| 296 | #[test] | ||
| 297 | fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restarts() { | ||
| 298 | let (source, sid, outline, paragraph, _) = fixture(); | ||
| 299 | let directory = tempfile::tempdir().unwrap(); | ||
| 300 | std::thread::scope(|scope| { | ||
| 301 | let mut writers = Vec::new(); | ||
| 302 | for actor in 0..12 { | ||
| 303 | let source = &source; | ||
| 304 | let path = directory.path().join(format!("{actor}.sqlite")); | ||
| 305 | writers.push(scope.spawn(move || { | ||
| 306 | let cache = Replica::create(&path, source).unwrap(); | ||
| 307 | for round in 0..4 { | ||
| 308 | for (object, change) in [ | ||
| 309 | ( | ||
| 310 | outline, | ||
| 311 | Change::Position { | ||
| 312 | x: (actor + 2) as f32 * 36.0, | ||
| 313 | y: (round + 2) as f32 * 36.0, | ||
| 314 | }, | ||
| 315 | ), | ||
| 316 | ( | ||
| 317 | outline, | ||
| 318 | Change::Width { | ||
| 319 | points: (actor + round + 2) as f32 * 36.0, | ||
| 320 | user_set: true, | ||
| 321 | }, | ||
| 322 | ), | ||
| 323 | (paragraph, Change::Collapsed((actor + round) % 2 != 0)), | ||
| 324 | ] { | ||
| 325 | assert!( | ||
| 326 | cache | ||
| 327 | .outline(&cache.snapshot().unwrap(), sid, object, change) | ||
| 328 | .unwrap() | ||
| 329 | .is_some() | ||
| 330 | ); | ||
| 331 | } | ||
| 332 | } | ||
| 333 | assert_eq!(cache.pending().unwrap().len(), 12); | ||
| 334 | })); | ||
| 335 | } | ||
| 336 | for writer in writers { | ||
| 337 | writer.join().unwrap(); | ||
| 338 | } | ||
| 339 | }); | ||
| 340 | let mut server = Server::new(&source); | ||
| 341 | let mut expected_layout = node(&source, sid, outline)["layout"].clone(); | ||
| 342 | let mut expected_collapse = None; | ||
| 343 | let mut reviewed = 0; | ||
| 344 | for round in 0..12 { | ||
| 345 | for actor in 0..12 { | ||
| 346 | let path = directory.path().join(format!("{actor}.sqlite")); | ||
| 347 | let cache = Replica::open(&path).unwrap(); | ||
| 348 | let first = cache.pending().unwrap()[0].clone(); | ||
| 349 | let onestore_offline::Operation::Outline(edit) = first.operation else { | ||
| 350 | panic!() | ||
| 351 | }; | ||
| 352 | let local = cache.snapshot().unwrap(); | ||
| 353 | let result = cache.sync_once(&mut server).unwrap(); | ||
| 354 | if result == Some((first.id, EditStatus::Conflict(ConflictKind::LayoutChanged))) { | ||
| 355 | let remote = cache.remote_snapshot().unwrap(); | ||
| 356 | cache | ||
| 357 | .rebase_layout_conflict(first.id, &local, &remote) | ||
| 358 | .unwrap(); | ||
| 359 | drop(cache); | ||
| 360 | let cache = Replica::open(&path).unwrap(); | ||
| 361 | assert!( | ||
| 362 | matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == first.id) | ||
| 363 | ); | ||
| 364 | reviewed += 1; | ||
| 365 | } else { | ||
| 366 | assert!( | ||
| 367 | matches!(result, Some((id, EditStatus::Published { .. })) if id == first.id) | ||
| 368 | ); | ||
| 369 | } | ||
| 370 | match edit.change { | ||
| 371 | Change::Position { x, y } => { | ||
| 372 | expected_layout["x"] = x.into(); | ||
| 373 | expected_layout["y"] = y.into(); | ||
| 374 | } | ||
| 375 | Change::Width { points, user_set } => { | ||
| 376 | expected_layout["max_width"] = points.into(); | ||
| 377 | expected_layout["width_set_by_user"] = user_set.into(); | ||
| 378 | } | ||
| 379 | Change::Collapsed(value) => expected_collapse = Some(u8::from(value)), | ||
| 380 | } | ||
| 381 | assert_eq!( | ||
| 382 | node(&server.durable, sid, outline)["layout"], | ||
| 383 | expected_layout | ||
| 384 | ); | ||
| 385 | assert_eq!( | ||
| 386 | node(&server.durable, sid, paragraph)["kind"]["collapse_state"], | ||
| 387 | serde_json::json!(expected_collapse) | ||
| 388 | ); | ||
| 389 | assert_eq!(text(&server.durable).2, "Original 🦀 é"); | ||
| 390 | } | ||
| 391 | for actor in 0..12 { | ||
| 392 | let cache = Replica::open(directory.path().join(format!("{actor}.sqlite"))).unwrap(); | ||
| 393 | assert_eq!(cache.pending().unwrap().len(), 11 - round); | ||
| 394 | } | ||
| 395 | } | ||
| 396 | assert!(reviewed >= 12); | ||
| 397 | for actor in 0..12 { | ||
| 398 | let cache = Replica::open(directory.path().join(format!("{actor}.sqlite"))).unwrap(); | ||
| 399 | assert!(cache.pending().unwrap().is_empty()); | ||
| 400 | for id in 1..=12 { | ||
| 401 | assert!(matches!( | ||
| 402 | cache.status(id).unwrap(), | ||
| 403 | Some(EditStatus::Published { .. }) | ||
| 404 | )); | ||
| 405 | } | ||
| 406 | } | ||
| 407 | } | ||
| 408 | |||
| 409 | #[test] | ||
| 410 | fn uncertain_layout_is_confirmed_by_revision_and_never_by_converged_values() { | ||
| 411 | let (source, sid, outline, _, text_id) = fixture(); | ||
| 412 | for fault in [Fault::UnknownBefore, Fault::UnknownAfter] { | ||
| 413 | let before = matches!(fault, Fault::UnknownBefore); | ||
| 414 | let directory = tempfile::tempdir().unwrap(); | ||
| 415 | let path = directory.path().join("cache.sqlite"); | ||
| 416 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 417 | let change = Change::Width { | ||
| 418 | points: 144.0, | ||
| 419 | user_set: true, | ||
| 420 | }; | ||
| 421 | let id = cache | ||
| 422 | .outline(&source, sid, outline, change) | ||
| 423 | .unwrap() | ||
| 424 | .unwrap(); | ||
| 425 | cache | ||
| 426 | .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ") | ||
| 427 | .unwrap(); | ||
| 428 | let local = cache.snapshot().unwrap(); | ||
| 429 | let mut server = Server::new(&source); | ||
| 430 | server.fault = fault; | ||
| 431 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 432 | let status = cache.status(id).unwrap().unwrap(); | ||
| 433 | assert!(matches!(status, EditStatus::AwaitingConfirmation { .. })); | ||
| 434 | drop(cache); | ||
| 435 | let cache = Replica::open(&path).unwrap(); | ||
| 436 | if before { | ||
| 437 | let converged = PreparedEdit::outline(&source, sid, outline, change).unwrap(); | ||
| 438 | server.visible = converged.as_bytes().to_vec(); | ||
| 439 | assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, status))); | ||
| 440 | assert!( | ||
| 441 | cache | ||
| 442 | .rebase_layout_conflict(id, &local, &server.visible) | ||
| 443 | .is_err() | ||
| 444 | ); | ||
| 445 | assert_eq!(cache.pending().unwrap().len(), 2); | ||
| 446 | assert!(cache.snapshot().unwrap() == local); | ||
| 447 | let archive = directory.path().join("recovery.sqlite"); | ||
| 448 | cache.export_recovery(&archive).unwrap(); | ||
| 449 | let recovery = onestore_offline::Recovery::open(&archive).unwrap(); | ||
| 450 | assert_eq!(recovery.pending().unwrap(), cache.pending().unwrap()); | ||
| 451 | assert_eq!(recovery.status(id).unwrap(), Some(status)); | ||
| 452 | assert_eq!(server.confirmations, 0); | ||
| 453 | } else { | ||
| 454 | assert!( | ||
| 455 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) | ||
| 456 | ); | ||
| 457 | assert_eq!( | ||
| 458 | node(&server.durable, sid, outline)["layout"]["max_width"], | ||
| 459 | 144.0 | ||
| 460 | ); | ||
| 461 | assert_eq!(server.confirmations, 1); | ||
| 462 | } | ||
| 463 | assert_eq!(server.publications, 1); | ||
| 464 | } | ||
| 465 | } | ||
| 466 | |||
| 467 | #[test] | ||
| 468 | fn native_moves_deletions_and_layout_changes_merge_or_retain_explicit_conflicts() { | ||
| 469 | let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); | ||
| 470 | let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one"); | ||
| 471 | let store = Store::parse(source).unwrap(); | ||
| 472 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 473 | let document = Document::parse(&index).unwrap(); | ||
| 474 | let mut server = Server::new(native); | ||
| 475 | let mut counts = [0; 3]; | ||
| 476 | let mut records = Vec::new(); | ||
| 477 | for (sid, page) in document.pages().unwrap() { | ||
| 478 | let space = &document.spaces[&sid]; | ||
| 479 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 480 | let Kind::Metadata { | ||
| 481 | title: Some(name), .. | ||
| 482 | } = &view.nodes[&view.roots[&2]].kind | ||
| 483 | else { | ||
| 484 | continue; | ||
| 485 | }; | ||
| 486 | if name == "Unicode rich text" { | ||
| 487 | continue; | ||
| 488 | } | ||
| 489 | let outlines: Vec<_> = view.nodes[&page] | ||
| 490 | .children | ||
| 491 | .iter() | ||
| 492 | .filter(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 493 | .copied() | ||
| 494 | .collect(); | ||
| 495 | if outlines.len() != 2 { | ||
| 496 | continue; | ||
| 497 | } | ||
| 498 | let outline = outlines[0]; | ||
| 499 | let mut pending = vec![outline]; | ||
| 500 | let mut target = None; | ||
| 501 | while let Some(id) = pending.pop() { | ||
| 502 | let node = &view.nodes[&id]; | ||
| 503 | pending.extend(&node.children); | ||
| 504 | if node.content.first().is_some_and(|text| matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text.starts_with("Target "))) { | ||
| 505 | target = Some(id); | ||
| 506 | } | ||
| 507 | } | ||
| 508 | let target = target.unwrap(); | ||
| 509 | let text_id = view.nodes[&view.nodes[&outlines[1]].children[0]].content[0]; | ||
| 510 | let (object, change, conflict) = match name.as_str() { | ||
| 511 | "Move outline" => ( | ||
| 512 | outline, | ||
| 513 | Change::Position { x: 252.0, y: 288.0 }, | ||
| 514 | Some(ConflictKind::LayoutChanged), | ||
| 515 | ), | ||
| 516 | "Resize outline" | "Automatic outline size" => ( | ||
| 517 | outline, | ||
| 518 | Change::Width { | ||
| 519 | points: 252.0, | ||
| 520 | user_set: true, | ||
| 521 | }, | ||
| 522 | Some(ConflictKind::LayoutChanged), | ||
| 523 | ), | ||
| 524 | "Delete outline" => ( | ||
| 525 | outline, | ||
| 526 | Change::Width { | ||
| 527 | points: 252.0, | ||
| 528 | user_set: true, | ||
| 529 | }, | ||
| 530 | Some(ConflictKind::TargetUnavailable), | ||
| 531 | ), | ||
| 532 | "Delete leaf" | "Delete subtree" | "Delete only paragraph" => ( | ||
| 533 | target, | ||
| 534 | Change::Collapsed(true), | ||
| 535 | Some(ConflictKind::TargetUnavailable), | ||
| 536 | ), | ||
| 537 | "Indent subtree" | "Outdent subtree" => ( | ||
| 538 | target, | ||
| 539 | Change::Collapsed(true), | ||
| 540 | Some(ConflictKind::StructureChanged), | ||
| 541 | ), | ||
| 542 | "Expand subtree" => (target, Change::Collapsed(false), None), | ||
| 543 | "Collapse subtree" | "Move leaf down" | "Move subtree down" | "Move subtree up" => { | ||
| 544 | (target, Change::Collapsed(true), None) | ||
| 545 | } | ||
| 546 | _ => panic!("Unexpected native case {name}"), | ||
| 547 | }; | ||
| 548 | let expected_layout = if conflict == Some(ConflictKind::TargetUnavailable) { | ||
| 549 | None | ||
| 550 | } else { | ||
| 551 | let mut layout = node(&server.visible, sid, object)["layout"].clone(); | ||
| 552 | match change { | ||
| 553 | Change::Position { x, y } => { | ||
| 554 | layout["x"] = x.into(); | ||
| 555 | layout["y"] = y.into(); | ||
| 556 | } | ||
| 557 | Change::Width { points, user_set } => { | ||
| 558 | layout["max_width"] = points.into(); | ||
| 559 | layout["width_set_by_user"] = user_set.into(); | ||
| 560 | } | ||
| 561 | Change::Collapsed(_) => {} | ||
| 562 | } | ||
| 563 | Some(layout) | ||
| 564 | }; | ||
| 565 | let directory = tempfile::tempdir().unwrap(); | ||
| 566 | let path = directory.path().join("cache.sqlite"); | ||
| 567 | let cache = Replica::create(&path, source).unwrap(); | ||
| 568 | let id = cache.outline(source, sid, object, change).unwrap().unwrap(); | ||
| 569 | let dependent = cache | ||
| 570 | .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ") | ||
| 571 | .unwrap() | ||
| 572 | .unwrap(); | ||
| 573 | let local = cache.snapshot().unwrap(); | ||
| 574 | let queue = cache.pending().unwrap(); | ||
| 575 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 576 | let mut retained = false; | ||
| 577 | if let Some(kind) = conflict { | ||
| 578 | assert_eq!(result, (id, EditStatus::Conflict(kind)), "{name}"); | ||
| 579 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 580 | assert!(cache.snapshot().unwrap() == local); | ||
| 581 | drop(cache); | ||
| 582 | let cache = Replica::open(&path).unwrap(); | ||
| 583 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Conflict(kind))); | ||
| 584 | if kind == ConflictKind::TargetUnavailable { | ||
| 585 | assert!( | ||
| 586 | cache | ||
| 587 | .rebase_layout_conflict(id, &local, &server.visible) | ||
| 588 | .is_err() | ||
| 589 | ); | ||
| 590 | counts[2] += 1; | ||
| 591 | retained = true; | ||
| 592 | } else { | ||
| 593 | cache | ||
| 594 | .rebase_layout_conflict(id, &local, &server.visible) | ||
| 595 | .unwrap(); | ||
| 596 | drop(cache); | ||
| 597 | let cache = Replica::open(&path).unwrap(); | ||
| 598 | assert!( | ||
| 599 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | ||
| 600 | ); | ||
| 601 | counts[1] += 1; | ||
| 602 | } | ||
| 603 | } else { | ||
| 604 | assert!( | ||
| 605 | matches!(result, (n, EditStatus::Published { .. }) if n == id), | ||
| 606 | "{name}" | ||
| 607 | ); | ||
| 608 | counts[0] += 1; | ||
| 609 | drop(cache); | ||
| 610 | } | ||
| 611 | let cache = Replica::open(&path).unwrap(); | ||
| 612 | if retained { | ||
| 613 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 614 | assert!(cache.snapshot().unwrap() == local); | ||
| 615 | } else { | ||
| 616 | assert!( | ||
| 617 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == dependent) | ||
| 618 | ); | ||
| 619 | assert_eq!( | ||
| 620 | node(&server.durable, sid, object)["layout"], | ||
| 621 | expected_layout.unwrap(), | ||
| 622 | "{name}" | ||
| 623 | ); | ||
| 624 | if matches!(change, Change::Collapsed(_)) { | ||
| 625 | assert_eq!( | ||
| 626 | node(&server.durable, sid, object)["kind"]["collapse_state"], | ||
| 627 | node(&local, sid, object)["kind"]["collapse_state"] | ||
| 628 | ); | ||
| 629 | } | ||
| 630 | assert!( | ||
| 631 | node(&server.durable, sid, text_id)["kind"]["text"] | ||
| 632 | .as_str() | ||
| 633 | .unwrap() | ||
| 634 | .starts_with("Local ") | ||
| 635 | ); | ||
| 636 | drop(cache); | ||
| 637 | let cache = Replica::open(&path).unwrap(); | ||
| 638 | for id in [id, dependent] { | ||
| 639 | assert!(matches!( | ||
| 640 | cache.status(id).unwrap(), | ||
| 641 | Some(EditStatus::Published { .. }) | ||
| 642 | )); | ||
| 643 | } | ||
| 644 | } | ||
| 645 | records.push(serde_json::json!({"name": name, "space": sid, "object": object, "change": change, "retained": retained, "dependent_text": text_id})); | ||
| 646 | } | ||
| 647 | assert_eq!(counts, [5, 5, 4]); | ||
| 648 | assert_eq!(server.publications, 18); | ||
| 649 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_OUTLINE_OUTPUT") { | ||
| 650 | let output = std::path::PathBuf::from(output); | ||
| 651 | assert!(output.is_absolute()); | ||
| 652 | std::fs::create_dir(&output).unwrap(); | ||
| 653 | std::fs::write(output.join("synthetic.one"), &server.durable).unwrap(); | ||
| 654 | std::fs::write( | ||
| 655 | output.with_extension("json"), | ||
| 656 | serde_json::to_vec_pretty(&records).unwrap(), | ||
| 657 | ) | ||
| 658 | .unwrap(); | ||
| 659 | } | ||
| 660 | } | ||
| 661 | |||
| 662 | #[test] | ||
| 663 | fn a_new_native_wrap_reservation_requires_review_before_width_replacement() { | ||
| 664 | let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); | ||
| 665 | let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one"); | ||
| 666 | let store = Store::parse(source).unwrap(); | ||
| 667 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 668 | let document = Document::parse(&index).unwrap(); | ||
| 669 | let (sid, page) = document.pages().unwrap().into_iter().find(|(sid, _)| { | ||
| 670 | let space = &document.spaces[sid]; | ||
| 671 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 672 | matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title: Some(name), .. } if name=="Move outline") | ||
| 673 | }).unwrap(); | ||
| 674 | let space = &document.spaces[&sid]; | ||
| 675 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 676 | let outline = *view.nodes[&page] | ||
| 677 | .children | ||
| 678 | .iter() | ||
| 679 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 680 | .unwrap(); | ||
| 681 | let old = node(source, sid, outline); | ||
| 682 | let remote = node(native, sid, outline); | ||
| 683 | assert_eq!(old["layout"]["max_width"], remote["layout"]["max_width"]); | ||
| 684 | assert_eq!( | ||
| 685 | old["layout"]["width_set_by_user"], | ||
| 686 | remote["layout"]["width_set_by_user"] | ||
| 687 | ); | ||
| 688 | assert!(remote["layout"]["reserved_width"].is_number()); | ||
| 689 | let directory = tempfile::tempdir().unwrap(); | ||
| 690 | let cache = Replica::create(directory.path().join("cache.sqlite"), source).unwrap(); | ||
| 691 | let id = cache | ||
| 692 | .outline( | ||
| 693 | source, | ||
| 694 | sid, | ||
| 695 | outline, | ||
| 696 | Change::Width { | ||
| 697 | points: 144.0, | ||
| 698 | user_set: true, | ||
| 699 | }, | ||
| 700 | ) | ||
| 701 | .unwrap() | ||
| 702 | .unwrap(); | ||
| 703 | let mut server = Server::new(native); | ||
| 704 | assert_eq!( | ||
| 705 | cache.sync_once(&mut server).unwrap(), | ||
| 706 | Some((id, EditStatus::Conflict(ConflictKind::LayoutChanged))) | ||
| 707 | ); | ||
| 708 | cache | ||
| 709 | .rebase_layout_conflict(id, &cache.snapshot().unwrap(), &server.visible) | ||
| 710 | .unwrap(); | ||
| 711 | assert!( | ||
| 712 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id) | ||
| 713 | ); | ||
| 714 | let after = node(&server.durable, sid, outline); | ||
| 715 | assert_eq!(after["layout"]["x"], remote["layout"]["x"]); | ||
| 716 | assert_eq!(after["layout"]["y"], remote["layout"]["y"]); | ||
| 717 | assert_eq!(after["layout"]["max_width"], 144.0); | ||
| 718 | assert!(after["layout"]["reserved_width"].is_null()); | ||
| 719 | } | ||
crates/onestore-offline/tests/sync/page.rs deleted-350| ... | @@ -1,350 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{Insertion, PageCreation}; | ||
| 3 | use onestore_offline::{Operation, Recovery}; | ||
| 4 | use std::collections::BTreeMap; | ||
| 5 | |||
| 6 | #[test] | ||
| 7 | fn twelve_replica_page_schedules_retain_acknowledged_pages_through_interruptions() { | ||
| 8 | for seed in 0..24_u64 { | ||
| 9 | let mut random = seed + 1956; | ||
| 10 | let mut input = Vec::new(); | ||
| 11 | for step in 0..48 { | ||
| 12 | random ^= random << 13; | ||
| 13 | random ^= random >> 7; | ||
| 14 | random ^= random << 17; | ||
| 15 | let mut action = random.to_le_bytes(); | ||
| 16 | if step < 12 { | ||
| 17 | action[0] = step; | ||
| 18 | action[1] = 1; | ||
| 19 | } | ||
| 20 | input.extend_from_slice(&action); | ||
| 21 | } | ||
| 22 | page_schedule::run(&input); | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | #[test] | ||
| 27 | fn version_two_uncertain_text_migrates_without_replay_or_lost_receipts() { | ||
| 28 | let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); | ||
| 29 | let (sid, oid, _) = text(&source); | ||
| 30 | let directory = tempfile::tempdir().unwrap(); | ||
| 31 | let path = directory.path().join("legacy.sqlite"); | ||
| 32 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 33 | let id = cache | ||
| 34 | .edit_text(&source, sid, oid, 0..0, "Old ") | ||
| 35 | .unwrap() | ||
| 36 | .unwrap(); | ||
| 37 | let snapshot = cache.snapshot().unwrap(); | ||
| 38 | let pending = cache.pending().unwrap(); | ||
| 39 | let store = Store::parse(&snapshot).unwrap(); | ||
| 40 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 41 | let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)]; | ||
| 42 | drop(cache); | ||
| 43 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 44 | db.execute_batch( | ||
| 45 | "DROP TABLE edits; DROP TABLE assets; | ||
| 46 | CREATE TABLE edits ( | ||
| 47 | id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), space TEXT NOT NULL, | ||
| 48 | object TEXT NOT NULL, before_text TEXT NOT NULL, | ||
| 49 | start INTEGER NOT NULL CHECK(start BETWEEN 0 AND 4294967295), | ||
| 50 | end INTEGER NOT NULL CHECK(end BETWEEN start AND 4294967295), replacement TEXT NOT NULL | ||
| 51 | ) STRICT; | ||
| 52 | ALTER TABLE attempt RENAME COLUMN revisions TO revision; | ||
| 53 | PRAGMA user_version=2;", | ||
| 54 | ) | ||
| 55 | .unwrap(); | ||
| 56 | db.execute( | ||
| 57 | "INSERT INTO edits VALUES (?1,?2,?3,'Original',0,0,'Old ')", | ||
| 58 | rusqlite::params![i64::try_from(id).unwrap(), sid.to_string(), oid.to_string()], | ||
| 59 | ) | ||
| 60 | .unwrap(); | ||
| 61 | db.execute( | ||
| 62 | "INSERT INTO attempt VALUES (1,?1,?2)", | ||
| 63 | rusqlite::params![i64::try_from(id).unwrap(), revision.to_string()], | ||
| 64 | ) | ||
| 65 | .unwrap(); | ||
| 66 | db.execute( | ||
| 67 | "INSERT INTO receipts VALUES (1000,?1)", | ||
| 68 | [revision.to_string()], | ||
| 69 | ) | ||
| 70 | .unwrap(); | ||
| 71 | db.execute("UPDATE sqlite_sequence SET seq=1000 WHERE name='edits'", []) | ||
| 72 | .unwrap(); | ||
| 73 | drop(db); | ||
| 74 | let cache = Replica::open(&path).unwrap(); | ||
| 75 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 76 | assert_eq!( | ||
| 77 | cache.status(id).unwrap(), | ||
| 78 | Some(EditStatus::AwaitingConfirmation { revision }) | ||
| 79 | ); | ||
| 80 | assert_eq!( | ||
| 81 | cache.status(1000).unwrap(), | ||
| 82 | Some(EditStatus::Published { revision }) | ||
| 83 | ); | ||
| 84 | assert!(cache.snapshot().unwrap() == snapshot); | ||
| 85 | let mut server = Server::new(&snapshot); | ||
| 86 | assert_eq!( | ||
| 87 | cache.sync_once(&mut server).unwrap(), | ||
| 88 | Some((id, EditStatus::Published { revision })) | ||
| 89 | ); | ||
| 90 | assert_eq!(server.publications, 0); | ||
| 91 | assert_eq!(server.confirmations, 1); | ||
| 92 | assert_eq!( | ||
| 93 | cache | ||
| 94 | .edit_text(&cache.snapshot().unwrap(), sid, oid, 0..0, "Next ") | ||
| 95 | .unwrap(), | ||
| 96 | Some(1001) | ||
| 97 | ); | ||
| 98 | } | ||
| 99 | |||
| 100 | #[test] | ||
| 101 | fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() { | ||
| 102 | let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); | ||
| 103 | let directory = tempfile::tempdir().unwrap(); | ||
| 104 | let mut server = Server::new(&source); | ||
| 105 | let mut expected = Vec::new(); | ||
| 106 | for actor in 0..12 { | ||
| 107 | let path = directory.path().join(format!("{actor}.sqlite")); | ||
| 108 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 109 | let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap(); | ||
| 110 | let id = cache.create_page(&source, &page).unwrap().unwrap(); | ||
| 111 | let insertion = Insertion::outline(page.object(), 36.0, 36.0, "Body", "Author").unwrap(); | ||
| 112 | cache | ||
| 113 | .insert(&cache.snapshot().unwrap(), page.space(), &insertion) | ||
| 114 | .unwrap() | ||
| 115 | .unwrap(); | ||
| 116 | cache | ||
| 117 | .edit_text( | ||
| 118 | &cache.snapshot().unwrap(), | ||
| 119 | page.space(), | ||
| 120 | insertion.text_object(), | ||
| 121 | 4..4, | ||
| 122 | &format!(" {actor}"), | ||
| 123 | ) | ||
| 124 | .unwrap() | ||
| 125 | .unwrap(); | ||
| 126 | let original = cache.pending().unwrap(); | ||
| 127 | assert!( | ||
| 128 | matches!(&original[0].operation, Operation::CreatePage(retained) if retained == &page) | ||
| 129 | ); | ||
| 130 | let local = cache.snapshot().unwrap(); | ||
| 131 | drop(cache); | ||
| 132 | let cache = Replica::open(&path).unwrap(); | ||
| 133 | assert_eq!(cache.pending().unwrap(), original); | ||
| 134 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 135 | for edit in original { | ||
| 136 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 137 | Some((published, EditStatus::Published { .. })) if published == edit.id)); | ||
| 138 | } | ||
| 139 | assert!(matches!( | ||
| 140 | cache.status(id).unwrap(), | ||
| 141 | Some(EditStatus::Published { .. }) | ||
| 142 | )); | ||
| 143 | expected.push(( | ||
| 144 | page.space(), | ||
| 145 | page.object(), | ||
| 146 | insertion.text_object(), | ||
| 147 | format!("Body {actor}"), | ||
| 148 | )); | ||
| 149 | } | ||
| 150 | let store = Store::parse(&server.durable).unwrap(); | ||
| 151 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 152 | let document = Document::parse(&index).unwrap(); | ||
| 153 | let pages = document.pages().unwrap(); | ||
| 154 | assert_eq!(pages.len(), 13); | ||
| 155 | for (actual, (sid, page, text, expected)) in pages[1..].iter().zip(&expected) { | ||
| 156 | assert_eq!(*actual, (*sid, *page)); | ||
| 157 | let space = &document.spaces[sid]; | ||
| 158 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 159 | assert!(matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text == expected)); | ||
| 160 | } | ||
| 161 | assert_eq!(server.publications, 36); | ||
| 162 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_OUTPUT") { | ||
| 163 | std::fs::create_dir(&output).unwrap(); | ||
| 164 | std::fs::write( | ||
| 165 | std::path::Path::new(&output).join("pages.one"), | ||
| 166 | &server.durable, | ||
| 167 | ) | ||
| 168 | .unwrap(); | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | #[test] | ||
| 173 | fn uncertain_page_publication_retains_both_revisions_and_never_replays() { | ||
| 174 | for fault in [ | ||
| 175 | Fault::UnknownBefore, | ||
| 176 | Fault::UnknownAfter, | ||
| 177 | Fault::PanicBefore, | ||
| 178 | Fault::PanicAfter, | ||
| 179 | ] { | ||
| 180 | let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); | ||
| 181 | let directory = tempfile::tempdir().unwrap(); | ||
| 182 | let path = directory.path().join("pages.sqlite"); | ||
| 183 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 184 | let page = PageCreation::new(None, Some("Created"), "Author").unwrap(); | ||
| 185 | let id = cache.create_page(&source, &page).unwrap().unwrap(); | ||
| 186 | let local = cache.snapshot().unwrap(); | ||
| 187 | let mut server = Server::new(&source); | ||
| 188 | server.fault = fault; | ||
| 189 | let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | ||
| 190 | cache.sync_once(&mut server) | ||
| 191 | })); | ||
| 192 | let attempted = cache.status(id).unwrap().unwrap(); | ||
| 193 | assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. })); | ||
| 194 | let archive = directory.path().join("recovery.sqlite"); | ||
| 195 | cache.export_recovery(&archive).unwrap(); | ||
| 196 | assert_eq!( | ||
| 197 | Recovery::open(&archive).unwrap().status(id).unwrap(), | ||
| 198 | Some(attempted) | ||
| 199 | ); | ||
| 200 | drop(cache); | ||
| 201 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 202 | let encoded: String = db | ||
| 203 | .query_row("SELECT revisions FROM attempt", [], |row| row.get(0)) | ||
| 204 | .unwrap(); | ||
| 205 | let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); | ||
| 206 | assert_eq!(revisions.len(), 2); | ||
| 207 | assert!(revisions.contains_key(&page.space())); | ||
| 208 | drop(db); | ||
| 209 | let cache = Replica::open(&path).unwrap(); | ||
| 210 | let observed = server.visible.clone(); | ||
| 211 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 212 | let visible = matches!(fault, Fault::UnknownAfter | Fault::PanicAfter); | ||
| 213 | assert_eq!(server.publications, 1); | ||
| 214 | assert_eq!(server.confirmations, usize::from(visible)); | ||
| 215 | if visible { | ||
| 216 | assert!(matches!(result.1, EditStatus::Published { .. })); | ||
| 217 | assert!(cache.snapshot().unwrap() == observed); | ||
| 218 | assert!(observed[..212] == server.durable[..212]); | ||
| 219 | assert!(observed[252..] == server.durable[252..]); | ||
| 220 | } else { | ||
| 221 | assert_eq!(result, (id, attempted)); | ||
| 222 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 223 | } | ||
| 224 | } | ||
| 225 | } | ||
| 226 | |||
| 227 | #[test] | ||
| 228 | fn surviving_page_revision_alone_does_not_confirm_section_publication() { | ||
| 229 | let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); | ||
| 230 | let directory = tempfile::tempdir().unwrap(); | ||
| 231 | let path = directory.path().join("pages.sqlite"); | ||
| 232 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 233 | let page = PageCreation::new(None, None, "Author").unwrap(); | ||
| 234 | let id = cache.create_page(&source, &page).unwrap().unwrap(); | ||
| 235 | let mut server = Server::new(&source); | ||
| 236 | server.fault = Fault::UnknownAfter; | ||
| 237 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 238 | let attempted = cache.status(id).unwrap().unwrap(); | ||
| 239 | drop(cache); | ||
| 240 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 241 | let encoded: String = db | ||
| 242 | .query_row("SELECT revisions FROM attempt", [], |row| row.get(0)) | ||
| 243 | .unwrap(); | ||
| 244 | let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); | ||
| 245 | drop(db); | ||
| 246 | let complete = server.visible.clone(); | ||
| 247 | let (&section, &retired) = revisions | ||
| 248 | .iter() | ||
| 249 | .find(|(sid, _)| **sid != page.space()) | ||
| 250 | .unwrap(); | ||
| 251 | let store = Store::parse(&complete).unwrap(); | ||
| 252 | let mut positions = Vec::new(); | ||
| 253 | for list in store.lists.values() { | ||
| 254 | for node in &list.nodes { | ||
| 255 | if node.id == 0x1e | ||
| 256 | && node.payload[..16] == retired.guid | ||
| 257 | && node.payload[16..20] == retired.n.to_le_bytes() | ||
| 258 | { | ||
| 259 | positions.push(node.offset); | ||
| 260 | } | ||
| 261 | } | ||
| 262 | } | ||
| 263 | assert_eq!(positions.len(), 1); | ||
| 264 | // Replacing only the revision identity models maintenance retiring the section proof. | ||
| 265 | let at = positions[0] + 4; | ||
| 266 | server.visible[at] ^= 0x40; | ||
| 267 | let store = Store::parse(&server.visible).unwrap(); | ||
| 268 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 269 | index.validate_current().unwrap(); | ||
| 270 | assert!(!index.spaces[&section].revisions.contains_key(&retired)); | ||
| 271 | assert!( | ||
| 272 | index.spaces[&page.space()] | ||
| 273 | .revisions | ||
| 274 | .contains_key(&revisions[&page.space()]) | ||
| 275 | ); | ||
| 276 | let cache = Replica::open(&path).unwrap(); | ||
| 277 | assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, attempted))); | ||
| 278 | assert_eq!(server.confirmations, 0); | ||
| 279 | assert_eq!(server.publications, 1); | ||
| 280 | server.visible = complete; | ||
| 281 | assert!(matches!( | ||
| 282 | cache.sync_once(&mut server).unwrap(), | ||
| 283 | Some((_, EditStatus::Published { .. })) | ||
| 284 | )); | ||
| 285 | assert_eq!(server.publications, 1); | ||
| 286 | } | ||
| 287 | |||
| 288 | #[test] | ||
| 289 | fn changed_page_anchor_requires_review_without_regenerating_dependent_identities() { | ||
| 290 | let source = | ||
| 291 | include_bytes!("../../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one"); | ||
| 292 | let remote = | ||
| 293 | include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); | ||
| 294 | let store = Store::parse(source).unwrap(); | ||
| 295 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 296 | let pages = Document::parse(&index).unwrap().pages().unwrap(); | ||
| 297 | let directory = tempfile::tempdir().unwrap(); | ||
| 298 | let path = directory.path().join("pages.sqlite"); | ||
| 299 | let cache = Replica::create(&path, source).unwrap(); | ||
| 300 | let page = PageCreation::new(Some(pages[4].0), Some("Created"), "Author").unwrap(); | ||
| 301 | let id = cache.create_page(source, &page).unwrap().unwrap(); | ||
| 302 | let insertion = | ||
| 303 | Insertion::outline(page.object(), 36.0, 36.0, "Retained body", "Author").unwrap(); | ||
| 304 | cache | ||
| 305 | .insert(&cache.snapshot().unwrap(), page.space(), &insertion) | ||
| 306 | .unwrap() | ||
| 307 | .unwrap(); | ||
| 308 | let local = cache.snapshot().unwrap(); | ||
| 309 | let pending = cache.pending().unwrap(); | ||
| 310 | let mut server = Server::new(remote); | ||
| 311 | assert_eq!( | ||
| 312 | cache.sync_once(&mut server).unwrap(), | ||
| 313 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | ||
| 314 | ); | ||
| 315 | assert!( | ||
| 316 | cache | ||
| 317 | .rebase_page_creation_conflict(id, source, remote, None) | ||
| 318 | .is_err() | ||
| 319 | ); | ||
| 320 | assert!( | ||
| 321 | cache | ||
| 322 | .rebase_page_creation_conflict(id, &local, source, None) | ||
| 323 | .is_err() | ||
| 324 | ); | ||
| 325 | cache | ||
| 326 | .rebase_page_creation_conflict(id, &local, remote, None) | ||
| 327 | .unwrap(); | ||
| 328 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 329 | assert_eq!(cache.pending().unwrap()[1], pending[1]); | ||
| 330 | let Operation::CreatePage(reviewed) = &cache.pending().unwrap()[0].operation else { | ||
| 331 | panic!() | ||
| 332 | }; | ||
| 333 | assert_eq!(*reviewed, page.reposition(None).unwrap()); | ||
| 334 | drop(cache); | ||
| 335 | let cache = Replica::open(&path).unwrap(); | ||
| 336 | for _ in 0..2 { | ||
| 337 | assert!(matches!( | ||
| 338 | cache.sync_once(&mut server).unwrap(), | ||
| 339 | Some((_, EditStatus::Published { .. })) | ||
| 340 | )); | ||
| 341 | } | ||
| 342 | assert_eq!(server.publications, 2); | ||
| 343 | let store = Store::parse(&server.durable).unwrap(); | ||
| 344 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 345 | let document = Document::parse(&index).unwrap(); | ||
| 346 | let space = &document.spaces[&page.space()]; | ||
| 347 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 348 | assert!(matches!(&view.nodes[&insertion.text_object()].kind, | ||
| 349 | Kind::RichText { text, .. } if text == "Retained body")); | ||
| 350 | } | ||
crates/onestore-offline/tests/sync/pages.rs deleted-631| ... | @@ -1,631 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{PageEdit, PagePosition}; | ||
| 3 | use onestore_offline::{Operation, Recovery}; | ||
| 4 | use std::collections::BTreeMap; | ||
| 5 | |||
| 6 | const SOURCE: &[u8] = | ||
| 7 | include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); | ||
| 8 | |||
| 9 | fn order(source: &[u8]) -> Vec<(ExGuid, u32)> { | ||
| 10 | let store = Store::parse(source).unwrap(); | ||
| 11 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 12 | index.validate_current().unwrap(); | ||
| 13 | let document = Document::parse(&index).unwrap(); | ||
| 14 | document | ||
| 15 | .pages() | ||
| 16 | .unwrap() | ||
| 17 | .iter() | ||
| 18 | .map(|(sid, _)| { | ||
| 19 | let space = &document.spaces[sid]; | ||
| 20 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 21 | let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else { | ||
| 22 | panic!() | ||
| 23 | }; | ||
| 24 | (*sid, level.unwrap_or(1)) | ||
| 25 | }) | ||
| 26 | .collect() | ||
| 27 | } | ||
| 28 | |||
| 29 | fn texts(source: &[u8]) -> BTreeMap<(ExGuid, ExGuid), String> { | ||
| 30 | let store = Store::parse(source).unwrap(); | ||
| 31 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 32 | let document = Document::parse(&index).unwrap(); | ||
| 33 | let mut texts = BTreeMap::new(); | ||
| 34 | for (sid, space) in &document.spaces { | ||
| 35 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 36 | for (oid, node) in &view.nodes { | ||
| 37 | if let Kind::RichText { text, .. } = &node.kind { | ||
| 38 | texts.insert((*sid, *oid), text.clone()); | ||
| 39 | } | ||
| 40 | } | ||
| 41 | } | ||
| 42 | texts | ||
| 43 | } | ||
| 44 | |||
| 45 | #[test] | ||
| 46 | fn atomic_page_batches_survive_reopen_with_dependent_text() { | ||
| 47 | let directory = tempfile::tempdir().unwrap(); | ||
| 48 | let path = directory.path().join("pages.sqlite"); | ||
| 49 | let cache = Replica::create(&path, SOURCE).unwrap(); | ||
| 50 | let before = order(SOURCE); | ||
| 51 | let edits: Vec<_> = before[3..6] | ||
| 52 | .iter() | ||
| 53 | .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap()) | ||
| 54 | .collect(); | ||
| 55 | let id = cache.pages(SOURCE, &edits).unwrap().unwrap(); | ||
| 56 | assert!(cache.pages(SOURCE, &edits).is_err()); | ||
| 57 | let mut expected_text = texts(SOURCE); | ||
| 58 | let (&(sid, oid), original) = expected_text | ||
| 59 | .iter() | ||
| 60 | .find(|(_, text)| text.starts_with("Body parent")) | ||
| 61 | .unwrap(); | ||
| 62 | let changed = format!("Offline {original}"); | ||
| 63 | expected_text.insert((sid, oid), changed); | ||
| 64 | cache | ||
| 65 | .edit_text(&cache.snapshot().unwrap(), sid, oid, 0..0, "Offline ") | ||
| 66 | .unwrap() | ||
| 67 | .unwrap(); | ||
| 68 | let queue = cache.pending().unwrap(); | ||
| 69 | assert!(matches!(&queue[0].operation, Operation::Pages(batch) if batch.edits == edits)); | ||
| 70 | let local = cache.snapshot().unwrap(); | ||
| 71 | drop(cache); | ||
| 72 | let cache = Replica::open(&path).unwrap(); | ||
| 73 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 74 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 75 | let mut server = Server::new(SOURCE); | ||
| 76 | for edit in queue { | ||
| 77 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 78 | Some((published, EditStatus::Published { .. })) if published == edit.id)); | ||
| 79 | } | ||
| 80 | let expected = [ | ||
| 81 | before[..3].to_vec(), | ||
| 82 | before[6..].to_vec(), | ||
| 83 | before[3..6].to_vec(), | ||
| 84 | ] | ||
| 85 | .concat(); | ||
| 86 | assert_eq!(order(&server.durable), expected); | ||
| 87 | assert_eq!(texts(&server.durable), expected_text); | ||
| 88 | assert!(matches!( | ||
| 89 | cache.status(id).unwrap(), | ||
| 90 | Some(EditStatus::Published { .. }) | ||
| 91 | )); | ||
| 92 | assert_eq!(server.publications, 2); | ||
| 93 | } | ||
| 94 | |||
| 95 | #[test] | ||
| 96 | fn indentation_only_edits_preserve_remote_page_movement() { | ||
| 97 | let directory = tempfile::tempdir().unwrap(); | ||
| 98 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | ||
| 99 | let before = order(SOURCE); | ||
| 100 | let sid = before[4].0; | ||
| 101 | let id = cache | ||
| 102 | .pages(SOURCE, &[PageEdit::set_level(sid, 3).unwrap()]) | ||
| 103 | .unwrap() | ||
| 104 | .unwrap(); | ||
| 105 | let mut server = Server::new(SOURCE); | ||
| 106 | PreparedEdit::pages( | ||
| 107 | SOURCE, | ||
| 108 | &[PageEdit::move_to(sid, Some(before[7].0), 2).unwrap()], | ||
| 109 | ) | ||
| 110 | .unwrap() | ||
| 111 | .commit(&mut server) | ||
| 112 | .unwrap(); | ||
| 113 | let mut expected = order(&server.durable); | ||
| 114 | expected.iter_mut().find(|p| p.0 == sid).unwrap().1 = 3; | ||
| 115 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 116 | Some((published, EditStatus::Published { .. })) if published == id)); | ||
| 117 | assert_eq!(order(&server.durable), expected); | ||
| 118 | } | ||
| 119 | |||
| 120 | #[test] | ||
| 121 | fn competing_page_moves_require_current_review_and_retain_intent_identities() { | ||
| 122 | let directory = tempfile::tempdir().unwrap(); | ||
| 123 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | ||
| 124 | let pages = order(SOURCE); | ||
| 125 | let sid = pages[6].0; | ||
| 126 | let original = PageEdit::move_to(sid, None, 1).unwrap(); | ||
| 127 | let id = cache | ||
| 128 | .pages(SOURCE, std::slice::from_ref(&original)) | ||
| 129 | .unwrap() | ||
| 130 | .unwrap(); | ||
| 131 | let mut server = Server::new(SOURCE); | ||
| 132 | PreparedEdit::pages( | ||
| 133 | SOURCE, | ||
| 134 | &[PageEdit::move_to(sid, Some(pages[0].0), 1).unwrap()], | ||
| 135 | ) | ||
| 136 | .unwrap() | ||
| 137 | .commit(&mut server) | ||
| 138 | .unwrap(); | ||
| 139 | let remote = server.visible.clone(); | ||
| 140 | let local = cache.snapshot().unwrap(); | ||
| 141 | assert_eq!( | ||
| 142 | cache.sync_once(&mut server).unwrap(), | ||
| 143 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | ||
| 144 | ); | ||
| 145 | assert_eq!(server.publications, 0); | ||
| 146 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 147 | let revised = original | ||
| 148 | .reposition(PagePosition::Before(Some(pages[2].0)), 1) | ||
| 149 | .unwrap(); | ||
| 150 | let fresh = PageEdit::move_to(sid, Some(pages[2].0), 1).unwrap(); | ||
| 151 | assert!( | ||
| 152 | cache | ||
| 153 | .rebase_pages_conflict(id, &local, &remote, &[fresh]) | ||
| 154 | .is_err() | ||
| 155 | ); | ||
| 156 | assert!( | ||
| 157 | cache | ||
| 158 | .rebase_pages_conflict(id, SOURCE, &remote, std::slice::from_ref(&revised)) | ||
| 159 | .is_err() | ||
| 160 | ); | ||
| 161 | assert!( | ||
| 162 | cache | ||
| 163 | .rebase_pages_conflict(id, &local, SOURCE, std::slice::from_ref(&revised)) | ||
| 164 | .is_err() | ||
| 165 | ); | ||
| 166 | cache | ||
| 167 | .rebase_pages_conflict(id, &local, &remote, std::slice::from_ref(&revised)) | ||
| 168 | .unwrap(); | ||
| 169 | assert!(matches!(&cache.pending().unwrap()[0].operation, | ||
| 170 | Operation::Pages(batch) if batch.edits == [revised.clone()])); | ||
| 171 | let expected = PreparedEdit::pages(&remote, &[revised]).unwrap(); | ||
| 172 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 173 | Some((published, EditStatus::Published { .. })) if published == id)); | ||
| 174 | assert_eq!(order(&server.durable), order(expected.as_bytes())); | ||
| 175 | } | ||
| 176 | |||
| 177 | #[test] | ||
| 178 | fn one_competing_level_prevents_publication_of_the_whole_batch() { | ||
| 179 | let directory = tempfile::tempdir().unwrap(); | ||
| 180 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | ||
| 181 | let pages = order(SOURCE); | ||
| 182 | let id = cache | ||
| 183 | .pages( | ||
| 184 | SOURCE, | ||
| 185 | &[ | ||
| 186 | PageEdit::set_level(pages[4].0, 3).unwrap(), | ||
| 187 | PageEdit::set_level(pages[5].0, 2).unwrap(), | ||
| 188 | ], | ||
| 189 | ) | ||
| 190 | .unwrap() | ||
| 191 | .unwrap(); | ||
| 192 | let local = cache.snapshot().unwrap(); | ||
| 193 | let mut server = Server::new(SOURCE); | ||
| 194 | PreparedEdit::pages(SOURCE, &[PageEdit::set_level(pages[5].0, 1).unwrap()]) | ||
| 195 | .unwrap() | ||
| 196 | .commit(&mut server) | ||
| 197 | .unwrap(); | ||
| 198 | let remote = server.durable.clone(); | ||
| 199 | assert_eq!( | ||
| 200 | cache.sync_once(&mut server).unwrap(), | ||
| 201 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | ||
| 202 | ); | ||
| 203 | assert_eq!(server.publications, 0); | ||
| 204 | assert_eq!(server.durable, remote); | ||
| 205 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 206 | } | ||
| 207 | |||
| 208 | #[test] | ||
| 209 | fn uncertain_page_batches_survive_recovery_and_do_not_replay() { | ||
| 210 | for fault in [ | ||
| 211 | Fault::UnknownBefore, | ||
| 212 | Fault::UnknownAfter, | ||
| 213 | Fault::PanicBefore, | ||
| 214 | Fault::PanicAfter, | ||
| 215 | ] { | ||
| 216 | let directory = tempfile::tempdir().unwrap(); | ||
| 217 | let path = directory.path().join("pages.sqlite"); | ||
| 218 | let mut cache = Replica::create(&path, SOURCE).unwrap(); | ||
| 219 | let pages = order(SOURCE); | ||
| 220 | let edits = [ | ||
| 221 | PageEdit::set_level(pages[4].0, 3).unwrap(), | ||
| 222 | PageEdit::set_level(pages[5].0, 2).unwrap(), | ||
| 223 | ]; | ||
| 224 | let id = cache.pages(SOURCE, &edits).unwrap().unwrap(); | ||
| 225 | let local = cache.snapshot().unwrap(); | ||
| 226 | let queue = cache.pending().unwrap(); | ||
| 227 | let mut server = Server::new(SOURCE); | ||
| 228 | server.fault = fault; | ||
| 229 | let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | ||
| 230 | cache.sync_once(&mut server) | ||
| 231 | })); | ||
| 232 | drop(cache); | ||
| 233 | cache = Replica::open(&path).unwrap(); | ||
| 234 | let attempted = cache.status(id).unwrap().unwrap(); | ||
| 235 | assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. })); | ||
| 236 | let archive = directory.path().join("recovery.sqlite"); | ||
| 237 | cache.export_recovery(&archive).unwrap(); | ||
| 238 | let recovery = Recovery::open(&archive).unwrap(); | ||
| 239 | assert_eq!(recovery.pending().unwrap(), queue); | ||
| 240 | assert_eq!(recovery.status(id).unwrap(), Some(attempted)); | ||
| 241 | assert_eq!(recovery.snapshot().unwrap(), local); | ||
| 242 | assert!( | ||
| 243 | cache | ||
| 244 | .rebase_pages_conflict(id, &local, SOURCE, &edits) | ||
| 245 | .is_err() | ||
| 246 | ); | ||
| 247 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 248 | assert_eq!(server.publications, 1); | ||
| 249 | if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) { | ||
| 250 | assert!(matches!(result.1, EditStatus::Published { .. })); | ||
| 251 | assert_eq!(server.confirmations, 1); | ||
| 252 | } else { | ||
| 253 | assert_eq!(result, (id, attempted)); | ||
| 254 | assert_eq!(server.confirmations, 0); | ||
| 255 | } | ||
| 256 | } | ||
| 257 | } | ||
| 258 | |||
| 259 | #[test] | ||
| 260 | fn an_unchanged_section_revision_cannot_confirm_a_changed_page() { | ||
| 261 | let before = order(SOURCE); | ||
| 262 | let sid = before[4].0; | ||
| 263 | let store = Store::parse(SOURCE).unwrap(); | ||
| 264 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 265 | let root = index.root; | ||
| 266 | let doc = Document::parse(&index).unwrap(); | ||
| 267 | let space = &doc.spaces[&root]; | ||
| 268 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 269 | let copy = *view.nodes.iter().find(|(_, node)| | ||
| 270 | matches!(&node.kind, Kind::Metadata { title: Some(title), .. } if title == "child")) | ||
| 271 | .unwrap().0; | ||
| 272 | let source = | ||
| 273 | onestore::replace_property_bytes(SOURCE, root, copy, 0x14001dff, &3_u32.to_le_bytes()) | ||
| 274 | .unwrap(); | ||
| 275 | let directory = tempfile::tempdir().unwrap(); | ||
| 276 | let path = directory.path().join("pages.sqlite"); | ||
| 277 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 278 | let id = cache | ||
| 279 | .pages(&source, &[PageEdit::set_level(sid, 3).unwrap()]) | ||
| 280 | .unwrap() | ||
| 281 | .unwrap(); | ||
| 282 | let mut server = Server::new(&source); | ||
| 283 | server.fault = Fault::UnknownAfter; | ||
| 284 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 285 | let attempted = cache.status(id).unwrap().unwrap(); | ||
| 286 | drop(cache); | ||
| 287 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 288 | let encoded: String = db | ||
| 289 | .query_row("SELECT revisions FROM attempt", [], |r| r.get(0)) | ||
| 290 | .unwrap(); | ||
| 291 | let proofs: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap(); | ||
| 292 | assert_eq!(proofs.len(), 2); | ||
| 293 | let store = Store::parse(&source).unwrap(); | ||
| 294 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 295 | assert_eq!( | ||
| 296 | proofs[&root], | ||
| 297 | index.spaces[&root].labels[&(ExGuid::default(), 1)] | ||
| 298 | ); | ||
| 299 | assert!(!index.spaces[&sid].revisions.contains_key(&proofs[&sid])); | ||
| 300 | drop(db); | ||
| 301 | let cache = Replica::open(&path).unwrap(); | ||
| 302 | let complete = server.visible.clone(); | ||
| 303 | server.visible = source; | ||
| 304 | assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, attempted))); | ||
| 305 | assert_eq!(server.confirmations, 0); | ||
| 306 | server.visible = complete; | ||
| 307 | assert!(matches!( | ||
| 308 | cache.sync_once(&mut server).unwrap(), | ||
| 309 | Some((_, EditStatus::Published { .. })) | ||
| 310 | )); | ||
| 311 | assert_eq!(server.publications, 1); | ||
| 312 | assert_eq!(server.confirmations, 1); | ||
| 313 | } | ||
| 314 | |||
| 315 | #[test] | ||
| 316 | fn an_explicit_anchor_is_retained_even_when_originally_in_place() { | ||
| 317 | let directory = tempfile::tempdir().unwrap(); | ||
| 318 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | ||
| 319 | let pages = order(SOURCE); | ||
| 320 | let id = cache | ||
| 321 | .pages( | ||
| 322 | SOURCE, | ||
| 323 | &[ | ||
| 324 | PageEdit::move_to(pages[6].0, Some(pages[7].0), 1).unwrap(), | ||
| 325 | PageEdit::set_level(pages[4].0, 3).unwrap(), | ||
| 326 | ], | ||
| 327 | ) | ||
| 328 | .unwrap() | ||
| 329 | .unwrap(); | ||
| 330 | let mut server = Server::new(SOURCE); | ||
| 331 | PreparedEdit::pages( | ||
| 332 | SOURCE, | ||
| 333 | &[PageEdit::move_to(pages[6].0, Some(pages[0].0), 1).unwrap()], | ||
| 334 | ) | ||
| 335 | .unwrap() | ||
| 336 | .commit(&mut server) | ||
| 337 | .unwrap(); | ||
| 338 | assert_eq!( | ||
| 339 | cache.sync_once(&mut server).unwrap(), | ||
| 340 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | ||
| 341 | ); | ||
| 342 | assert_eq!(server.publications, 0); | ||
| 343 | } | ||
| 344 | |||
| 345 | #[test] | ||
| 346 | fn convergent_page_indentation_requires_confirmation_without_republishing() { | ||
| 347 | let directory = tempfile::tempdir().unwrap(); | ||
| 348 | let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap(); | ||
| 349 | let sid = order(SOURCE)[4].0; | ||
| 350 | let id = cache | ||
| 351 | .pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()]) | ||
| 352 | .unwrap() | ||
| 353 | .unwrap(); | ||
| 354 | let mut server = Server::new(SOURCE); | ||
| 355 | PreparedEdit::pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()]) | ||
| 356 | .unwrap() | ||
| 357 | .commit(&mut server) | ||
| 358 | .unwrap(); | ||
| 359 | server.fault = Fault::Confirm; | ||
| 360 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 361 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 362 | assert_eq!(server.publications, 0); | ||
| 363 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 364 | Some((published, EditStatus::Published { .. })) if published == id)); | ||
| 365 | assert_eq!(server.publications, 0); | ||
| 366 | assert_eq!(server.confirmations, 2); | ||
| 367 | } | ||
| 368 | |||
| 369 | #[test] | ||
| 370 | fn schema_ten_migration_preserves_multi_space_uncertainty() { | ||
| 371 | let directory = tempfile::tempdir().unwrap(); | ||
| 372 | let path = directory.path().join("pages.sqlite"); | ||
| 373 | let cache = Replica::create(&path, SOURCE).unwrap(); | ||
| 374 | let page = onestore::PageCreation::new(None, Some("Migration"), "Author").unwrap(); | ||
| 375 | let id = cache.create_page(SOURCE, &page).unwrap().unwrap(); | ||
| 376 | let mut server = Server::new(SOURCE); | ||
| 377 | server.fault = Fault::UnknownBefore; | ||
| 378 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 379 | let local = cache.snapshot().unwrap(); | ||
| 380 | let queue = cache.pending().unwrap(); | ||
| 381 | let status = cache.status(id).unwrap(); | ||
| 382 | drop(cache); | ||
| 383 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 384 | db.pragma_update(None, "user_version", 10).unwrap(); | ||
| 385 | let proofs: String = db | ||
| 386 | .query_row("SELECT revisions FROM attempt", [], |r| r.get(0)) | ||
| 387 | .unwrap(); | ||
| 388 | drop(db); | ||
| 389 | let cache = Replica::open(&path).unwrap(); | ||
| 390 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 391 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 392 | assert_eq!(cache.status(id).unwrap(), status); | ||
| 393 | assert_eq!( | ||
| 394 | cache.sync_once(&mut server).unwrap(), | ||
| 395 | Some((id, status.unwrap())) | ||
| 396 | ); | ||
| 397 | assert_eq!(server.publications, 1); | ||
| 398 | drop(cache); | ||
| 399 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 400 | assert_eq!( | ||
| 401 | db.query_row("SELECT revisions FROM attempt", [], |r| r | ||
| 402 | .get::<_, String>(0)) | ||
| 403 | .unwrap(), | ||
| 404 | proofs | ||
| 405 | ); | ||
| 406 | assert_eq!( | ||
| 407 | db.pragma_query_value(None, "user_version", |r| r.get::<_, u32>(0)) | ||
| 408 | .unwrap(), | ||
| 409 | 11 | ||
| 410 | ); | ||
| 411 | } | ||
| 412 | |||
| 413 | #[test] | ||
| 414 | fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() { | ||
| 415 | let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) | ||
| 416 | .join("../../corpus/page-lifecycle/movement"); | ||
| 417 | let provenance: serde_json::Value = | ||
| 418 | serde_json::from_slice(&std::fs::read(root.join("provenance.json")).unwrap()).unwrap(); | ||
| 419 | let phases = provenance["cold"].as_object().unwrap(); | ||
| 420 | assert_eq!(phases.len(), 9); | ||
| 421 | let original = order(SOURCE); | ||
| 422 | let original_text = texts(SOURCE); | ||
| 423 | let (&(text_space, text_object), body) = original_text | ||
| 424 | .iter() | ||
| 425 | .find(|(_, text)| text.starts_with("Body parent")) | ||
| 426 | .unwrap(); | ||
| 427 | let mut expected_text = original_text.clone(); | ||
| 428 | expected_text.insert((text_space, text_object), format!("Offline {body}")); | ||
| 429 | let mut counts = [0; 2]; | ||
| 430 | for mode in ["indent", "group"] { | ||
| 431 | for phase in phases.keys() { | ||
| 432 | let directory = tempfile::tempdir().unwrap(); | ||
| 433 | let path = directory.path().join("pages.sqlite"); | ||
| 434 | let cache = Replica::create(&path, SOURCE).unwrap(); | ||
| 435 | let edits = if mode == "indent" { | ||
| 436 | vec![PageEdit::set_level(original[4].0, 3).unwrap()] | ||
| 437 | } else { | ||
| 438 | original[3..6] | ||
| 439 | .iter() | ||
| 440 | .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap()) | ||
| 441 | .collect() | ||
| 442 | }; | ||
| 443 | let id = cache.pages(SOURCE, &edits).unwrap().unwrap(); | ||
| 444 | let text_id = cache | ||
| 445 | .edit_text( | ||
| 446 | &cache.snapshot().unwrap(), | ||
| 447 | text_space, | ||
| 448 | text_object, | ||
| 449 | 0..0, | ||
| 450 | "Offline ", | ||
| 451 | ) | ||
| 452 | .unwrap() | ||
| 453 | .unwrap(); | ||
| 454 | let native = std::fs::read(root.join(phase).join("notebook/Lifecycle.one")).unwrap(); | ||
| 455 | let mut server = Server::new(&native); | ||
| 456 | let local = cache.snapshot().unwrap(); | ||
| 457 | let expected_conflict = if mode == "indent" { | ||
| 458 | matches!( | ||
| 459 | phase.as_str(), | ||
| 460 | "04-subpage-move" | "07-promoted-root" | "08-collapsed-group-move" | ||
| 461 | ) | ||
| 462 | } else { | ||
| 463 | !matches!( | ||
| 464 | phase.as_str(), | ||
| 465 | "02-promoted-parent" | "03-selected-group-move" | "06-promoted-level-two" | ||
| 466 | ) | ||
| 467 | }; | ||
| 468 | let first = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 469 | assert_eq!(first.0, id); | ||
| 470 | let mut reviewed = edits.clone(); | ||
| 471 | if expected_conflict { | ||
| 472 | assert_eq!( | ||
| 473 | first.1, | ||
| 474 | EditStatus::Conflict(ConflictKind::StructureChanged), | ||
| 475 | "{mode}:{phase}" | ||
| 476 | ); | ||
| 477 | assert_eq!(server.publications, 0); | ||
| 478 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 479 | if mode == "indent" && phase == "08-collapsed-group-move" { | ||
| 480 | reviewed[0] = reviewed[0].reposition(PagePosition::Keep, 1).unwrap(); | ||
| 481 | } | ||
| 482 | cache | ||
| 483 | .rebase_pages_conflict(id, &local, &native, &reviewed) | ||
| 484 | .unwrap(); | ||
| 485 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 486 | Some((published, EditStatus::Published { .. })) if published == id)); | ||
| 487 | } else { | ||
| 488 | assert!( | ||
| 489 | matches!(first.1, EditStatus::Published { .. }), | ||
| 490 | "{mode}:{phase}" | ||
| 491 | ); | ||
| 492 | } | ||
| 493 | counts[usize::from(expected_conflict)] += 1; | ||
| 494 | drop(cache); | ||
| 495 | let cache = Replica::open(&path).unwrap(); | ||
| 496 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 497 | Some((published, EditStatus::Published { .. })) if published == text_id)); | ||
| 498 | assert!(cache.pending().unwrap().is_empty()); | ||
| 499 | let mut expected = order(&native); | ||
| 500 | for edit in &reviewed { | ||
| 501 | let at = expected.iter().position(|p| p.0 == edit.space()).unwrap(); | ||
| 502 | expected[at].1 = edit.level(); | ||
| 503 | if let PagePosition::Before(before) = edit.position() { | ||
| 504 | let page = expected.remove(at); | ||
| 505 | let at = before.map_or(expected.len(), |sid| { | ||
| 506 | expected.iter().position(|p| p.0 == sid).unwrap() | ||
| 507 | }); | ||
| 508 | expected.insert(at, page); | ||
| 509 | } | ||
| 510 | } | ||
| 511 | assert_eq!(order(&server.durable), expected, "{mode}:{phase}"); | ||
| 512 | assert_eq!(texts(&server.durable), expected_text, "{mode}:{phase}"); | ||
| 513 | assert_eq!( | ||
| 514 | server.publications, | ||
| 515 | 1 + usize::from(expected != order(&native)) | ||
| 516 | ); | ||
| 517 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_EDIT_OUTPUT") { | ||
| 518 | let output = std::path::Path::new(&output); | ||
| 519 | assert!(output.is_absolute()); | ||
| 520 | let path = output.join(format!("{mode}-{phase}")); | ||
| 521 | std::fs::create_dir_all(&path).unwrap(); | ||
| 522 | std::fs::write(path.join("Lifecycle.one"), &server.durable).unwrap(); | ||
| 523 | std::fs::write( | ||
| 524 | path.join("manifest.json"), | ||
| 525 | serde_json::to_vec_pretty( | ||
| 526 | &serde_json::json!({"mode": mode, "phase": phase, "original": edits, | ||
| 527 | "reviewed": reviewed, "conflict": expected_conflict, | ||
| 528 | "publications": server.publications, "confirmations": server.confirmations, | ||
| 529 | "page_receipt": format!("{:?}", cache.status(id).unwrap()), | ||
| 530 | "text_receipt": format!("{:?}", cache.status(text_id).unwrap())}), | ||
| 531 | ) | ||
| 532 | .unwrap(), | ||
| 533 | ) | ||
| 534 | .unwrap(); | ||
| 535 | } | ||
| 536 | } | ||
| 537 | } | ||
| 538 | assert_eq!(counts, [9, 9]); | ||
| 539 | } | ||
| 540 | |||
| 541 | #[test] | ||
| 542 | fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() { | ||
| 543 | let mut source = onestore::create_section("pages.one", "Sentinel", "Author").unwrap(); | ||
| 544 | let mut created = Vec::new(); | ||
| 545 | for _ in 0..36 { | ||
| 546 | let page = onestore::PageCreation::new(None, Some("Same title"), "Author").unwrap(); | ||
| 547 | source = PreparedEdit::create_page(&source, &page) | ||
| 548 | .unwrap() | ||
| 549 | .as_bytes() | ||
| 550 | .to_vec(); | ||
| 551 | created.push(page); | ||
| 552 | } | ||
| 553 | let mut expected = vec![order(&source)[0]]; | ||
| 554 | let mut expected_text = texts(&source); | ||
| 555 | let directory = tempfile::tempdir().unwrap(); | ||
| 556 | let mut queues = Vec::new(); | ||
| 557 | for (actor, group) in created.chunks_exact(3).enumerate() { | ||
| 558 | let path = directory.path().join(format!("{actor}.sqlite")); | ||
| 559 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 560 | let edits = [ | ||
| 561 | PageEdit::move_to(group[1].space(), Some(group[0].space()), 1).unwrap(), | ||
| 562 | PageEdit::set_level(group[2].space(), 2).unwrap(), | ||
| 563 | ]; | ||
| 564 | cache.pages(&source, &edits).unwrap().unwrap(); | ||
| 565 | let body = format!("Actor {actor} 🦀 é"); | ||
| 566 | let insertion = | ||
| 567 | onestore::Insertion::outline(group[1].object(), 36.0, 36.0, &body, "Author").unwrap(); | ||
| 568 | cache | ||
| 569 | .insert(&cache.snapshot().unwrap(), group[1].space(), &insertion) | ||
| 570 | .unwrap() | ||
| 571 | .unwrap(); | ||
| 572 | expected.extend([ | ||
| 573 | (group[1].space(), 1), | ||
| 574 | (group[0].space(), 1), | ||
| 575 | (group[2].space(), 2), | ||
| 576 | ]); | ||
| 577 | expected_text.insert((group[1].space(), insertion.text_object()), body); | ||
| 578 | queues.push((path, cache.pending().unwrap())); | ||
| 579 | } | ||
| 580 | let mut server = Server::new(&source); | ||
| 581 | for (path, queue) in &queues { | ||
| 582 | let cache = Replica::open(path).unwrap(); | ||
| 583 | assert_eq!(cache.pending().unwrap(), *queue); | ||
| 584 | for edit in queue { | ||
| 585 | assert!(matches!(cache.sync_once(&mut server).unwrap(), | ||
| 586 | Some((published, EditStatus::Published { .. })) if published == edit.id)); | ||
| 587 | } | ||
| 588 | assert!(cache.pending().unwrap().is_empty()); | ||
| 589 | } | ||
| 590 | assert_eq!(server.publications, 24); | ||
| 591 | assert_eq!(order(&server.durable), expected); | ||
| 592 | assert_eq!(texts(&server.durable), expected_text); | ||
| 593 | for (path, queue) in &queues { | ||
| 594 | let cache = Replica::open(path).unwrap(); | ||
| 595 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | ||
| 596 | assert_eq!(order(&cache.snapshot().unwrap()), expected); | ||
| 597 | for edit in queue { | ||
| 598 | assert!(matches!( | ||
| 599 | cache.status(edit.id).unwrap(), | ||
| 600 | Some(EditStatus::Published { .. }) | ||
| 601 | )); | ||
| 602 | } | ||
| 603 | } | ||
| 604 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_CLIENT_OUTPUT") { | ||
| 605 | let output = std::path::Path::new(&output); | ||
| 606 | assert!(output.is_absolute()); | ||
| 607 | std::fs::create_dir_all(output).unwrap(); | ||
| 608 | std::fs::write(output.join("pages.one"), &server.durable).unwrap(); | ||
| 609 | let operations: Vec<_> = queues | ||
| 610 | .iter() | ||
| 611 | .map(|(_, queue)| { | ||
| 612 | queue | ||
| 613 | .iter() | ||
| 614 | .map(|edit| { | ||
| 615 | serde_json::json!({"id": edit.id, "space": edit.space, | ||
| 616 | "operation": edit.operation}) | ||
| 617 | }) | ||
| 618 | .collect::<Vec<_>>() | ||
| 619 | }) | ||
| 620 | .collect(); | ||
| 621 | std::fs::write( | ||
| 622 | output.join("manifest.json"), | ||
| 623 | serde_json::to_vec_pretty( | ||
| 624 | &serde_json::json!({"operations": operations, "publications": server.publications, | ||
| 625 | "expected_order": expected, "pages": 37}), | ||
| 626 | ) | ||
| 627 | .unwrap(), | ||
| 628 | ) | ||
| 629 | .unwrap(); | ||
| 630 | } | ||
| 631 | } | ||
crates/onestore-offline/tests/sync/tree.rs deleted-821| ... | @@ -1,821 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{Insertion, TreeEdit}; | ||
| 3 | |||
| 4 | #[test] | ||
| 5 | fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() { | ||
| 6 | let mut random = 1940_u64; | ||
| 7 | for _ in 0..60 { | ||
| 8 | let input: Vec<_> = (0..384) | ||
| 9 | .map(|_| { | ||
| 10 | random ^= random << 13; | ||
| 11 | random ^= random >> 7; | ||
| 12 | random ^= random << 17; | ||
| 13 | random as u8 | ||
| 14 | }) | ||
| 15 | .collect(); | ||
| 16 | tree_schedule::run(&input); | ||
| 17 | } | ||
| 18 | } | ||
| 19 | |||
| 20 | fn fixture() -> (Vec<u8>, ExGuid, ExGuid, [ExGuid; 4], [ExGuid; 4]) { | ||
| 21 | let (mut source, sid, outline, first, text) = super::outline::fixture(); | ||
| 22 | let mut paragraphs = [first; 4]; | ||
| 23 | let mut texts = [text; 4]; | ||
| 24 | for at in 1..4 { | ||
| 25 | let insertion = | ||
| 26 | Insertion::paragraph(outline, None, &format!("Sibling {at}"), "Author").unwrap(); | ||
| 27 | source = PreparedEdit::insert(&source, sid, &insertion) | ||
| 28 | .unwrap() | ||
| 29 | .as_bytes() | ||
| 30 | .to_vec(); | ||
| 31 | paragraphs[at] = insertion.object(); | ||
| 32 | texts[at] = insertion.text_object(); | ||
| 33 | } | ||
| 34 | (source, sid, outline, paragraphs, texts) | ||
| 35 | } | ||
| 36 | |||
| 37 | fn children(source: &[u8], sid: ExGuid, object: ExGuid) -> Vec<ExGuid> { | ||
| 38 | let store = Store::parse(source).unwrap(); | ||
| 39 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 40 | index.validate_current().unwrap(); | ||
| 41 | let document = Document::parse(&index).unwrap(); | ||
| 42 | let space = &document.spaces[&sid]; | ||
| 43 | space.revisions[&space.contexts[&ExGuid::default()]].nodes[&object] | ||
| 44 | .children | ||
| 45 | .clone() | ||
| 46 | } | ||
| 47 | |||
| 48 | #[test] | ||
| 49 | fn move_preserves_remote_content_and_dependent_edits_through_reopen() { | ||
| 50 | let (source, sid, outline, paragraphs, texts) = fixture(); | ||
| 51 | let directory = tempfile::tempdir().unwrap(); | ||
| 52 | let path = directory.path().join("cache.sqlite"); | ||
| 53 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 54 | let intent = TreeEdit::move_to(paragraphs[0], outline, None, "Offline").unwrap(); | ||
| 55 | let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); | ||
| 56 | let dependent = cache | ||
| 57 | .edit_text(&cache.snapshot().unwrap(), sid, texts[0], 0..0, "Local ") | ||
| 58 | .unwrap() | ||
| 59 | .unwrap(); | ||
| 60 | let local = cache.snapshot().unwrap(); | ||
| 61 | let queue = cache.pending().unwrap(); | ||
| 62 | drop(cache); | ||
| 63 | let cache = Replica::open(&path).unwrap(); | ||
| 64 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 65 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 66 | let edited = PreparedEdit::format( | ||
| 67 | &source, | ||
| 68 | sid, | ||
| 69 | texts[0], | ||
| 70 | 0..8, | ||
| 71 | &[onestore::TextAttribute::Bold(true)], | ||
| 72 | ) | ||
| 73 | .unwrap(); | ||
| 74 | let descendant = | ||
| 75 | Insertion::paragraph(paragraphs[0], None, "New remote child", "Remote").unwrap(); | ||
| 76 | let remote = PreparedEdit::insert(edited.as_bytes(), sid, &descendant).unwrap(); | ||
| 77 | let mut server = Server::new(remote.as_bytes()); | ||
| 78 | for expected in [id, dependent] { | ||
| 79 | assert!( | ||
| 80 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) | ||
| 81 | ); | ||
| 82 | } | ||
| 83 | assert_eq!( | ||
| 84 | children(&server.durable, sid, outline), | ||
| 85 | [paragraphs[1], paragraphs[2], paragraphs[3], paragraphs[0]] | ||
| 86 | ); | ||
| 87 | assert_eq!( | ||
| 88 | children(&server.durable, sid, paragraphs[0]), | ||
| 89 | [descendant.object()] | ||
| 90 | ); | ||
| 91 | let node = super::outline::node(&server.durable, sid, texts[0]); | ||
| 92 | assert_eq!(node["kind"]["text"], "Local Original 🦀 é"); | ||
| 93 | let store = Store::parse(&server.durable).unwrap(); | ||
| 94 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 95 | let document = Document::parse(&index).unwrap(); | ||
| 96 | let space = &document.spaces[&sid]; | ||
| 97 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 98 | assert_eq!(view.text_runs(texts[0]).unwrap()[0].format.bold, Some(true)); | ||
| 99 | assert!(cache.pending().unwrap().is_empty()); | ||
| 100 | assert_eq!(server.publications, 2); | ||
| 101 | } | ||
| 102 | |||
| 103 | #[test] | ||
| 104 | fn queued_format_split_and_delete_reconcile_equivalent_immutable_style_identities() { | ||
| 105 | let (source, sid, _, _, texts) = fixture(); | ||
| 106 | let directory = tempfile::tempdir().unwrap(); | ||
| 107 | let path = directory.path().join("cache.sqlite"); | ||
| 108 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 109 | cache | ||
| 110 | .format( | ||
| 111 | &source, | ||
| 112 | sid, | ||
| 113 | texts[0], | ||
| 114 | 0..8, | ||
| 115 | &[onestore::TextAttribute::Bold(true)], | ||
| 116 | ) | ||
| 117 | .unwrap(); | ||
| 118 | let split = onestore::ParagraphSplit::new(texts[0], 4, "Author").unwrap(); | ||
| 119 | cache | ||
| 120 | .split(&cache.snapshot().unwrap(), sid, &split) | ||
| 121 | .unwrap(); | ||
| 122 | cache | ||
| 123 | .tree( | ||
| 124 | &cache.snapshot().unwrap(), | ||
| 125 | sid, | ||
| 126 | &TreeEdit::delete(split.object(), "Author").unwrap(), | ||
| 127 | ) | ||
| 128 | .unwrap(); | ||
| 129 | cache | ||
| 130 | .edit_text(&cache.snapshot().unwrap(), sid, texts[0], 0..0, "Local ") | ||
| 131 | .unwrap(); | ||
| 132 | let queue = cache.pending().unwrap(); | ||
| 133 | drop(cache); | ||
| 134 | let mut server = Server::new(&source); | ||
| 135 | for intent in queue { | ||
| 136 | let cache = Replica::open(&path).unwrap(); | ||
| 137 | assert!( | ||
| 138 | matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == intent.id) | ||
| 139 | ); | ||
| 140 | } | ||
| 141 | assert_eq!( | ||
| 142 | super::outline::node(&server.durable, sid, texts[0])["kind"]["text"], | ||
| 143 | "Local Orig" | ||
| 144 | ); | ||
| 145 | } | ||
| 146 | |||
| 147 | #[test] | ||
| 148 | fn native_empty_child_list_normalization_preserves_deletion_and_its_dependents() { | ||
| 149 | let source = include_bytes!("../../../../corpus/outline-edit/empty-children/before.one"); | ||
| 150 | let remote = include_bytes!("../../../../corpus/outline-edit/empty-children/remote.one"); | ||
| 151 | let (sid, intent): (ExGuid, TreeEdit) = serde_json::from_str(include_str!( | ||
| 152 | "../../../../corpus/outline-edit/empty-children/intent.json" | ||
| 153 | )) | ||
| 154 | .unwrap(); | ||
| 155 | let store = Store::parse(source).unwrap(); | ||
| 156 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 157 | let document = Document::parse(&index).unwrap(); | ||
| 158 | let space = &document.spaces[&sid]; | ||
| 159 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 160 | let tail = view.nodes[&intent.object()].content[0]; | ||
| 161 | let (outline_id, outline) = view | ||
| 162 | .nodes | ||
| 163 | .iter() | ||
| 164 | .find(|(_, node)| node.children.contains(&intent.object())) | ||
| 165 | .unwrap(); | ||
| 166 | let left = view.nodes[&outline.children[0]].content[0]; | ||
| 167 | for changed in [false, true] { | ||
| 168 | let directory = tempfile::tempdir().unwrap(); | ||
| 169 | let path = directory.path().join("cache.sqlite"); | ||
| 170 | let cache = Replica::create(&path, source).unwrap(); | ||
| 171 | let deletion = cache.tree(source, sid, &intent).unwrap().unwrap(); | ||
| 172 | let dependent = cache | ||
| 173 | .edit_text(&cache.snapshot().unwrap(), sid, left, 0..0, "Local ") | ||
| 174 | .unwrap() | ||
| 175 | .unwrap(); | ||
| 176 | let local = cache.snapshot().unwrap(); | ||
| 177 | drop(cache); | ||
| 178 | let cache = Replica::open(&path).unwrap(); | ||
| 179 | let mut server = if changed { | ||
| 180 | let edited = PreparedEdit::text(remote, sid, tail, 0..2, "Changed").unwrap(); | ||
| 181 | Server::new(edited.as_bytes()) | ||
| 182 | } else { | ||
| 183 | Server::new(remote) | ||
| 184 | }; | ||
| 185 | if changed { | ||
| 186 | assert_eq!( | ||
| 187 | cache.sync_once(&mut server).unwrap(), | ||
| 188 | Some((deletion, EditStatus::Conflict(ConflictKind::ContentChanged))) | ||
| 189 | ); | ||
| 190 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 191 | assert_eq!(cache.pending().unwrap().len(), 2); | ||
| 192 | assert_eq!(server.publications, 0); | ||
| 193 | } else { | ||
| 194 | for expected in [deletion, dependent] { | ||
| 195 | assert!( | ||
| 196 | matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == expected) | ||
| 197 | ); | ||
| 198 | } | ||
| 199 | assert!(cache.pending().unwrap().is_empty()); | ||
| 200 | assert_eq!(server.publications, 2); | ||
| 201 | assert!(!children(&server.durable, sid, *outline_id).contains(&intent.object())); | ||
| 202 | let node = super::outline::node(&server.durable, sid, left); | ||
| 203 | assert!(node["kind"]["text"].as_str().unwrap().starts_with("Local ")); | ||
| 204 | } | ||
| 205 | } | ||
| 206 | } | ||
| 207 | |||
| 208 | #[test] | ||
| 209 | fn deletion_requires_review_of_remote_content_and_preserves_later_work() { | ||
| 210 | let (source, sid, outline, paragraphs, texts) = fixture(); | ||
| 211 | let child = Insertion::paragraph(paragraphs[0], None, "Descendant", "Author").unwrap(); | ||
| 212 | let source = PreparedEdit::insert(&source, sid, &child) | ||
| 213 | .unwrap() | ||
| 214 | .as_bytes() | ||
| 215 | .to_vec(); | ||
| 216 | for remote in [ | ||
| 217 | PreparedEdit::text(&source, sid, texts[0], 0..0, "Remote ") | ||
| 218 | .unwrap() | ||
| 219 | .as_bytes() | ||
| 220 | .to_vec(), | ||
| 221 | PreparedEdit::text(&source, sid, child.text_object(), 0..0, "Remote ") | ||
| 222 | .unwrap() | ||
| 223 | .as_bytes() | ||
| 224 | .to_vec(), | ||
| 225 | PreparedEdit::format( | ||
| 226 | &source, | ||
| 227 | sid, | ||
| 228 | child.text_object(), | ||
| 229 | 0..10, | ||
| 230 | &[onestore::TextAttribute::Italic(true)], | ||
| 231 | ) | ||
| 232 | .unwrap() | ||
| 233 | .as_bytes() | ||
| 234 | .to_vec(), | ||
| 235 | PreparedEdit::insert( | ||
| 236 | &source, | ||
| 237 | sid, | ||
| 238 | &Insertion::paragraph(paragraphs[0], None, "New child", "Remote").unwrap(), | ||
| 239 | ) | ||
| 240 | .unwrap() | ||
| 241 | .as_bytes() | ||
| 242 | .to_vec(), | ||
| 243 | ] { | ||
| 244 | let directory = tempfile::tempdir().unwrap(); | ||
| 245 | let path = directory.path().join("cache.sqlite"); | ||
| 246 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 247 | let intent = TreeEdit::delete(paragraphs[0], "Offline").unwrap(); | ||
| 248 | let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); | ||
| 249 | let dependent = cache | ||
| 250 | .edit_text(&cache.snapshot().unwrap(), sid, texts[1], 0..0, "Local ") | ||
| 251 | .unwrap() | ||
| 252 | .unwrap(); | ||
| 253 | let queue = cache.pending().unwrap(); | ||
| 254 | let local = cache.snapshot().unwrap(); | ||
| 255 | let mut server = Server::new(&remote); | ||
| 256 | assert_eq!( | ||
| 257 | cache.sync_once(&mut server).unwrap(), | ||
| 258 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | ||
| 259 | ); | ||
| 260 | assert_eq!(server.publications, 0); | ||
| 261 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 262 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 263 | assert!(cache.rebase_tree_conflict(id, &source, &remote).is_err()); | ||
| 264 | assert!(cache.rebase_tree_conflict(id, &local, &source).is_err()); | ||
| 265 | assert!( | ||
| 266 | cache | ||
| 267 | .rebase_tree_conflict(dependent, &local, &remote) | ||
| 268 | .is_err() | ||
| 269 | ); | ||
| 270 | assert!(cache.rebase_conflict(id, &local, &remote, 0..0).is_err()); | ||
| 271 | let archive = directory.path().join("recovery.sqlite"); | ||
| 272 | cache.export_recovery(&archive).unwrap(); | ||
| 273 | let recovery = onestore_offline::Recovery::open(&archive).unwrap(); | ||
| 274 | assert_eq!(recovery.pending().unwrap(), queue); | ||
| 275 | assert_eq!(recovery.status(id).unwrap(), cache.status(id).unwrap()); | ||
| 276 | assert!(recovery.snapshot().unwrap() == local); | ||
| 277 | drop(cache); | ||
| 278 | let cache = Replica::open(&path).unwrap(); | ||
| 279 | assert_eq!( | ||
| 280 | cache.status(id).unwrap(), | ||
| 281 | Some(EditStatus::Conflict(ConflictKind::ContentChanged)) | ||
| 282 | ); | ||
| 283 | cache.rebase_tree_conflict(id, &local, &remote).unwrap(); | ||
| 284 | assert_eq!(cache.pending().unwrap()[1], queue[1]); | ||
| 285 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 286 | for expected in [id, dependent] { | ||
| 287 | assert!( | ||
| 288 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) | ||
| 289 | ); | ||
| 290 | } | ||
| 291 | assert_eq!(children(&server.durable, sid, outline), paragraphs[1..]); | ||
| 292 | assert_eq!( | ||
| 293 | super::outline::node(&server.durable, sid, texts[1])["kind"]["text"], | ||
| 294 | "Local Sibling 1" | ||
| 295 | ); | ||
| 296 | } | ||
| 297 | } | ||
| 298 | |||
| 299 | #[test] | ||
| 300 | fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() { | ||
| 301 | let (source, sid, outline, p, texts) = fixture(); | ||
| 302 | let insertion = Insertion::paragraph(outline, Some(p[0]), "New sibling", "Remote").unwrap(); | ||
| 303 | let cases = [ | ||
| 304 | ( | ||
| 305 | PreparedEdit::insert(&source, sid, &insertion) | ||
| 306 | .unwrap() | ||
| 307 | .as_bytes() | ||
| 308 | .to_vec(), | ||
| 309 | None, | ||
| 310 | ), | ||
| 311 | ( | ||
| 312 | PreparedEdit::tree(&source, sid, &TreeEdit::delete(p[1], "Remote").unwrap()) | ||
| 313 | .unwrap() | ||
| 314 | .as_bytes() | ||
| 315 | .to_vec(), | ||
| 316 | None, | ||
| 317 | ), | ||
| 318 | ( | ||
| 319 | PreparedEdit::tree( | ||
| 320 | &source, | ||
| 321 | sid, | ||
| 322 | &TreeEdit::move_to(p[1], outline, None, "Remote").unwrap(), | ||
| 323 | ) | ||
| 324 | .unwrap() | ||
| 325 | .as_bytes() | ||
| 326 | .to_vec(), | ||
| 327 | None, | ||
| 328 | ), | ||
| 329 | ( | ||
| 330 | PreparedEdit::tree( | ||
| 331 | &source, | ||
| 332 | sid, | ||
| 333 | &TreeEdit::move_to(p[0], outline, Some(p[2]), "Remote").unwrap(), | ||
| 334 | ) | ||
| 335 | .unwrap() | ||
| 336 | .as_bytes() | ||
| 337 | .to_vec(), | ||
| 338 | Some(ConflictKind::StructureChanged), | ||
| 339 | ), | ||
| 340 | ( | ||
| 341 | PreparedEdit::tree( | ||
| 342 | &source, | ||
| 343 | sid, | ||
| 344 | &TreeEdit::move_to(p[0], p[1], None, "Remote").unwrap(), | ||
| 345 | ) | ||
| 346 | .unwrap() | ||
| 347 | .as_bytes() | ||
| 348 | .to_vec(), | ||
| 349 | Some(ConflictKind::StructureChanged), | ||
| 350 | ), | ||
| 351 | ( | ||
| 352 | PreparedEdit::tree(&source, sid, &TreeEdit::delete(p[0], "Remote").unwrap()) | ||
| 353 | .unwrap() | ||
| 354 | .as_bytes() | ||
| 355 | .to_vec(), | ||
| 356 | Some(ConflictKind::TargetUnavailable), | ||
| 357 | ), | ||
| 358 | ]; | ||
| 359 | for (remote, expected) in cases { | ||
| 360 | let directory = tempfile::tempdir().unwrap(); | ||
| 361 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); | ||
| 362 | let intent = TreeEdit::move_to(p[0], outline, None, "Offline").unwrap(); | ||
| 363 | let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); | ||
| 364 | let mut server = Server::new(&remote); | ||
| 365 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 366 | assert_eq!(result.0, id); | ||
| 367 | if let Some(kind) = expected { | ||
| 368 | assert_eq!(result.1, EditStatus::Conflict(kind)); | ||
| 369 | assert_eq!(server.publications, 0); | ||
| 370 | } else { | ||
| 371 | assert!(matches!(result.1, EditStatus::Published { .. })); | ||
| 372 | assert_eq!(children(&server.durable, sid, outline).last(), Some(&p[0])); | ||
| 373 | assert_eq!( | ||
| 374 | super::outline::node(&server.durable, sid, texts[0])["kind"]["text"], | ||
| 375 | "Original 🦀 é" | ||
| 376 | ); | ||
| 377 | } | ||
| 378 | } | ||
| 379 | } | ||
| 380 | |||
| 381 | #[test] | ||
| 382 | fn moved_destination_and_missing_anchor_retain_conflicts_but_unrelated_text_does_not() { | ||
| 383 | let (source, sid, outline, p, texts) = fixture(); | ||
| 384 | let child = Insertion::paragraph(p[1], None, "Anchor", "Author").unwrap(); | ||
| 385 | let source = PreparedEdit::insert(&source, sid, &child) | ||
| 386 | .unwrap() | ||
| 387 | .as_bytes() | ||
| 388 | .to_vec(); | ||
| 389 | let intent = TreeEdit::move_to(p[0], p[1], Some(child.object()), "Offline").unwrap(); | ||
| 390 | let cases = [ | ||
| 391 | ( | ||
| 392 | PreparedEdit::tree( | ||
| 393 | &source, | ||
| 394 | sid, | ||
| 395 | &TreeEdit::move_to(p[1], p[2], None, "Remote").unwrap(), | ||
| 396 | ) | ||
| 397 | .unwrap() | ||
| 398 | .as_bytes() | ||
| 399 | .to_vec(), | ||
| 400 | ConflictKind::StructureChanged, | ||
| 401 | ), | ||
| 402 | ( | ||
| 403 | PreparedEdit::tree( | ||
| 404 | &source, | ||
| 405 | sid, | ||
| 406 | &TreeEdit::move_to(child.object(), outline, None, "Remote").unwrap(), | ||
| 407 | ) | ||
| 408 | .unwrap() | ||
| 409 | .as_bytes() | ||
| 410 | .to_vec(), | ||
| 411 | ConflictKind::UnsupportedEdit, | ||
| 412 | ), | ||
| 413 | ]; | ||
| 414 | for (remote, kind) in cases { | ||
| 415 | let directory = tempfile::tempdir().unwrap(); | ||
| 416 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); | ||
| 417 | let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); | ||
| 418 | let mut server = Server::new(&remote); | ||
| 419 | assert_eq!( | ||
| 420 | cache.sync_once(&mut server).unwrap(), | ||
| 421 | Some((id, EditStatus::Conflict(kind))) | ||
| 422 | ); | ||
| 423 | assert_eq!(server.publications, 0); | ||
| 424 | } | ||
| 425 | let directory = tempfile::tempdir().unwrap(); | ||
| 426 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); | ||
| 427 | let id = cache | ||
| 428 | .tree(&source, sid, &TreeEdit::delete(p[0], "Offline").unwrap()) | ||
| 429 | .unwrap() | ||
| 430 | .unwrap(); | ||
| 431 | let remote = PreparedEdit::text(&source, sid, texts[1], 0..0, "Remote ").unwrap(); | ||
| 432 | let mut server = Server::new(remote.as_bytes()); | ||
| 433 | assert!( | ||
| 434 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | ||
| 435 | ); | ||
| 436 | assert_eq!( | ||
| 437 | super::outline::node(&server.durable, sid, texts[1])["kind"]["text"], | ||
| 438 | "Remote Sibling 1" | ||
| 439 | ); | ||
| 440 | } | ||
| 441 | |||
| 442 | #[test] | ||
| 443 | fn unknown_tree_attempts_require_revision_evidence_even_when_effect_is_visible() { | ||
| 444 | let (source, sid, outline, p, _) = fixture(); | ||
| 445 | for deletion in [false, true] { | ||
| 446 | for fault in [ | ||
| 447 | Fault::UnknownBefore, | ||
| 448 | Fault::UnknownAfter, | ||
| 449 | Fault::Before, | ||
| 450 | Fault::Committed, | ||
| 451 | ] { | ||
| 452 | let directory = tempfile::tempdir().unwrap(); | ||
| 453 | let path = directory.path().join("cache.sqlite"); | ||
| 454 | let cache = Replica::create(&path, &source).unwrap(); | ||
| 455 | let intent = if deletion { | ||
| 456 | TreeEdit::delete(p[0], "Offline") | ||
| 457 | } else { | ||
| 458 | TreeEdit::move_to(p[0], outline, None, "Offline") | ||
| 459 | } | ||
| 460 | .unwrap(); | ||
| 461 | let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); | ||
| 462 | let mut server = Server::new(&source); | ||
| 463 | server.fault = fault; | ||
| 464 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 465 | let local = cache.snapshot().unwrap(); | ||
| 466 | let before = cache.status(id).unwrap(); | ||
| 467 | drop(cache); | ||
| 468 | let cache = Replica::open(&path).unwrap(); | ||
| 469 | assert_eq!(cache.status(id).unwrap(), before); | ||
| 470 | if matches!(fault, Fault::UnknownBefore) { | ||
| 471 | let independent = if deletion { | ||
| 472 | TreeEdit::delete(p[0], "Other") | ||
| 473 | } else { | ||
| 474 | TreeEdit::move_to(p[0], outline, None, "Other") | ||
| 475 | } | ||
| 476 | .unwrap(); | ||
| 477 | server = Server::new( | ||
| 478 | PreparedEdit::tree(&source, sid, &independent) | ||
| 479 | .unwrap() | ||
| 480 | .as_bytes(), | ||
| 481 | ); | ||
| 482 | assert!( | ||
| 483 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::AwaitingConfirmation { .. })) if n == id) | ||
| 484 | ); | ||
| 485 | assert!( | ||
| 486 | cache | ||
| 487 | .rebase_tree_conflict(id, &local, &server.visible) | ||
| 488 | .is_err() | ||
| 489 | ); | ||
| 490 | assert_eq!(server.publications, 0); | ||
| 491 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 492 | } else if matches!(fault, Fault::Committed) { | ||
| 493 | assert!(matches!( | ||
| 494 | cache.status(id).unwrap(), | ||
| 495 | Some(EditStatus::Published { .. }) | ||
| 496 | )); | ||
| 497 | } else { | ||
| 498 | assert!( | ||
| 499 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | ||
| 500 | ); | ||
| 501 | assert_eq!( | ||
| 502 | server.publications, | ||
| 503 | if matches!(fault, Fault::Before) { 2 } else { 1 } | ||
| 504 | ); | ||
| 505 | let saved = cache.snapshot().unwrap(); | ||
| 506 | assert!(saved[..212] == server.durable[..212]); | ||
| 507 | assert!(saved[252..] == server.durable[252..]); | ||
| 508 | assert_eq!( | ||
| 509 | children(&saved, sid, outline), | ||
| 510 | children(&server.durable, sid, outline) | ||
| 511 | ); | ||
| 512 | } | ||
| 513 | } | ||
| 514 | } | ||
| 515 | } | ||
| 516 | |||
| 517 | #[test] | ||
| 518 | fn independently_satisfied_move_confirms_without_republication() { | ||
| 519 | let (source, sid, outline, p, _) = fixture(); | ||
| 520 | let directory = tempfile::tempdir().unwrap(); | ||
| 521 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); | ||
| 522 | let id = cache | ||
| 523 | .tree( | ||
| 524 | &source, | ||
| 525 | sid, | ||
| 526 | &TreeEdit::move_to(p[0], outline, None, "Offline").unwrap(), | ||
| 527 | ) | ||
| 528 | .unwrap() | ||
| 529 | .unwrap(); | ||
| 530 | let remote = PreparedEdit::tree( | ||
| 531 | &source, | ||
| 532 | sid, | ||
| 533 | &TreeEdit::move_to(p[0], outline, None, "Remote").unwrap(), | ||
| 534 | ) | ||
| 535 | .unwrap(); | ||
| 536 | let mut server = Server::new(remote.as_bytes()); | ||
| 537 | assert!( | ||
| 538 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | ||
| 539 | ); | ||
| 540 | assert_eq!(server.publications, 0); | ||
| 541 | assert_eq!(server.confirmations, 1); | ||
| 542 | } | ||
| 543 | |||
| 544 | #[test] | ||
| 545 | fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay() { | ||
| 546 | let source = | ||
| 547 | include_bytes!("../../../../corpus/outline-edit/tree/before/notebook/synthetic.one"); | ||
| 548 | let store = Store::parse(source).unwrap(); | ||
| 549 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 550 | let document = Document::parse(&index).unwrap(); | ||
| 551 | let (sid, page) = document.pages().unwrap().into_iter().find(|(sid, _)| { | ||
| 552 | let space = &document.spaces[sid]; | ||
| 553 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 554 | matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title: Some(title), .. } if title == "Delete sole cell paragraph") | ||
| 555 | }).unwrap(); | ||
| 556 | let space = &document.spaces[&sid]; | ||
| 557 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 558 | let mut pending = vec![page]; | ||
| 559 | let mut selected = None; | ||
| 560 | let mut other = None; | ||
| 561 | while let Some(id) = pending.pop() { | ||
| 562 | let node = &view.nodes[&id]; | ||
| 563 | pending.extend(&node.children); | ||
| 564 | pending.extend(&node.content); | ||
| 565 | if matches!(node.kind, Kind::Cell { .. }) { | ||
| 566 | let paragraph = node.children[0]; | ||
| 567 | let text = view.nodes[&paragraph].content[0]; | ||
| 568 | if matches!(&view.nodes[&text].kind, Kind::RichText { text, .. } if text.starts_with("Target ")) | ||
| 569 | { | ||
| 570 | selected = Some((id, paragraph)); | ||
| 571 | } else { | ||
| 572 | other = Some((id, text)); | ||
| 573 | } | ||
| 574 | } | ||
| 575 | } | ||
| 576 | let (cell, target) = selected.unwrap(); | ||
| 577 | let (other_cell, other_text) = other.unwrap(); | ||
| 578 | for move_out in [false, true] { | ||
| 579 | for competing_child in [false, true] { | ||
| 580 | let directory = tempfile::tempdir().unwrap(); | ||
| 581 | let path = directory.path().join("cell.sqlite"); | ||
| 582 | let cache = Replica::create(&path, source).unwrap(); | ||
| 583 | let intent = if move_out { | ||
| 584 | TreeEdit::move_to(target, other_cell, None, "Offline") | ||
| 585 | } else { | ||
| 586 | TreeEdit::delete(target, "Offline") | ||
| 587 | } | ||
| 588 | .unwrap(); | ||
| 589 | let id = cache.tree(source, sid, &intent).unwrap().unwrap(); | ||
| 590 | let local = cache.snapshot().unwrap(); | ||
| 591 | let replacement = children(&local, sid, cell); | ||
| 592 | assert_eq!(replacement.len(), 1); | ||
| 593 | assert_ne!(replacement[0], target); | ||
| 594 | let replacement_text: ExGuid = | ||
| 595 | super::outline::node(&local, sid, replacement[0])["content"][0] | ||
| 596 | .as_str() | ||
| 597 | .unwrap() | ||
| 598 | .parse() | ||
| 599 | .unwrap(); | ||
| 600 | let dependent = cache | ||
| 601 | .edit_text(&local, sid, replacement_text, 0..0, "Local replacement") | ||
| 602 | .unwrap() | ||
| 603 | .unwrap(); | ||
| 604 | let local = cache.snapshot().unwrap(); | ||
| 605 | let queue = cache.pending().unwrap(); | ||
| 606 | drop(cache); | ||
| 607 | let cache = Replica::open(&path).unwrap(); | ||
| 608 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 609 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 610 | let remote = if competing_child { | ||
| 611 | PreparedEdit::insert( | ||
| 612 | source, | ||
| 613 | sid, | ||
| 614 | &Insertion::paragraph(cell, None, "Remote sibling", "Remote").unwrap(), | ||
| 615 | ) | ||
| 616 | .unwrap() | ||
| 617 | .as_bytes() | ||
| 618 | .to_vec() | ||
| 619 | } else { | ||
| 620 | PreparedEdit::text(source, sid, other_text, 0..0, "Remote ") | ||
| 621 | .unwrap() | ||
| 622 | .as_bytes() | ||
| 623 | .to_vec() | ||
| 624 | }; | ||
| 625 | let mut server = Server::new(&remote); | ||
| 626 | if competing_child { | ||
| 627 | assert_eq!( | ||
| 628 | cache.sync_once(&mut server).unwrap(), | ||
| 629 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | ||
| 630 | ); | ||
| 631 | assert_eq!(server.publications, 0); | ||
| 632 | assert!(cache.rebase_tree_conflict(id, &local, &remote).is_err()); | ||
| 633 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 634 | assert_eq!(cache.snapshot().unwrap(), local); | ||
| 635 | } else { | ||
| 636 | for expected in [id, dependent] { | ||
| 637 | assert!( | ||
| 638 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) | ||
| 639 | ); | ||
| 640 | } | ||
| 641 | assert_eq!(children(&server.durable, sid, cell), replacement); | ||
| 642 | assert_eq!( | ||
| 643 | super::outline::node(&server.durable, sid, replacement_text)["kind"]["text"], | ||
| 644 | "Local replacement" | ||
| 645 | ); | ||
| 646 | assert_eq!( | ||
| 647 | super::outline::node(&server.durable, sid, other_text)["kind"]["text"], | ||
| 648 | "Remote Other cell" | ||
| 649 | ); | ||
| 650 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") { | ||
| 651 | let output = std::path::PathBuf::from(output) | ||
| 652 | .join(if move_out { "cell-move" } else { "cell-delete" }) | ||
| 653 | .join("candidate"); | ||
| 654 | std::fs::create_dir_all(&output).unwrap(); | ||
| 655 | std::fs::write(output.join("synthetic.one"), &server.durable).unwrap(); | ||
| 656 | } | ||
| 657 | } | ||
| 658 | } | ||
| 659 | } | ||
| 660 | } | ||
| 661 | |||
| 662 | #[test] | ||
| 663 | fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_content() { | ||
| 664 | let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); | ||
| 665 | let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one"); | ||
| 666 | let store = Store::parse(source).unwrap(); | ||
| 667 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 668 | let document = Document::parse(&index).unwrap(); | ||
| 669 | let mut server = Server::new(native); | ||
| 670 | let mut counts = [0; 3]; | ||
| 671 | let mut records = Vec::new(); | ||
| 672 | for (sid, page) in document.pages().unwrap() { | ||
| 673 | let space = &document.spaces[&sid]; | ||
| 674 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 675 | let Kind::Metadata { | ||
| 676 | title: Some(name), .. | ||
| 677 | } = &view.nodes[&view.roots[&2]].kind | ||
| 678 | else { | ||
| 679 | continue; | ||
| 680 | }; | ||
| 681 | if name == "Unicode rich text" { | ||
| 682 | continue; | ||
| 683 | } | ||
| 684 | let outlines: Vec<_> = view.nodes[&page] | ||
| 685 | .children | ||
| 686 | .iter() | ||
| 687 | .copied() | ||
| 688 | .filter(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) | ||
| 689 | .collect(); | ||
| 690 | if outlines.len() != 2 { | ||
| 691 | continue; | ||
| 692 | } | ||
| 693 | let outline = outlines[0]; | ||
| 694 | let other = view.nodes[&outlines[1]].children[0]; | ||
| 695 | let dependent_text = view.nodes[&other].content[0]; | ||
| 696 | let mut pending = vec![outline]; | ||
| 697 | let mut paragraphs = std::collections::BTreeMap::new(); | ||
| 698 | while let Some(id) = pending.pop() { | ||
| 699 | let node = &view.nodes[&id]; | ||
| 700 | pending.extend(&node.children); | ||
| 701 | if let Some(text) = node.content.first() | ||
| 702 | && let Kind::RichText { text, .. } = &view.nodes[text].kind | ||
| 703 | { | ||
| 704 | paragraphs.insert(text.as_str(), id); | ||
| 705 | } | ||
| 706 | } | ||
| 707 | let target = *paragraphs | ||
| 708 | .iter() | ||
| 709 | .find(|(text, _)| text.starts_with("Target ")) | ||
| 710 | .unwrap() | ||
| 711 | .1; | ||
| 712 | let (intent, conflict) = match name.as_str() { | ||
| 713 | "Move leaf down" | "Move subtree down" => { | ||
| 714 | (TreeEdit::move_to(target, outline, None, "Offline"), None) | ||
| 715 | } | ||
| 716 | "Move subtree up" => ( | ||
| 717 | TreeEdit::move_to(target, outline, Some(paragraphs["Anchor"]), "Offline"), | ||
| 718 | None, | ||
| 719 | ), | ||
| 720 | "Indent subtree" | "Outdent subtree" => ( | ||
| 721 | TreeEdit::delete(target, "Offline"), | ||
| 722 | Some(ConflictKind::StructureChanged), | ||
| 723 | ), | ||
| 724 | "Collapse subtree" | "Expand subtree" => ( | ||
| 725 | TreeEdit::delete(target, "Offline"), | ||
| 726 | Some(ConflictKind::ContentChanged), | ||
| 727 | ), | ||
| 728 | "Delete leaf" | "Delete subtree" | "Delete only paragraph" => ( | ||
| 729 | TreeEdit::delete(target, "Offline"), | ||
| 730 | Some(ConflictKind::TargetUnavailable), | ||
| 731 | ), | ||
| 732 | "Delete outline" => ( | ||
| 733 | TreeEdit::delete(outline, "Offline"), | ||
| 734 | Some(ConflictKind::TargetUnavailable), | ||
| 735 | ), | ||
| 736 | "Move outline" | "Resize outline" | "Automatic outline size" => ( | ||
| 737 | TreeEdit::delete(outline, "Offline"), | ||
| 738 | Some(ConflictKind::ContentChanged), | ||
| 739 | ), | ||
| 740 | _ => panic!("{name}"), | ||
| 741 | }; | ||
| 742 | let intent = intent.unwrap(); | ||
| 743 | let directory = tempfile::tempdir().unwrap(); | ||
| 744 | let path = directory.path().join("native.sqlite"); | ||
| 745 | let cache = Replica::create(&path, source).unwrap(); | ||
| 746 | let id = cache.tree(source, sid, &intent).unwrap().unwrap(); | ||
| 747 | let dependent = cache | ||
| 748 | .edit_text( | ||
| 749 | &cache.snapshot().unwrap(), | ||
| 750 | sid, | ||
| 751 | dependent_text, | ||
| 752 | 0..0, | ||
| 753 | "Offline ", | ||
| 754 | ) | ||
| 755 | .unwrap() | ||
| 756 | .unwrap(); | ||
| 757 | let local = cache.snapshot().unwrap(); | ||
| 758 | let queue = cache.pending().unwrap(); | ||
| 759 | let old_publications = server.publications; | ||
| 760 | let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); | ||
| 761 | assert_eq!(actual, id); | ||
| 762 | if let Some(kind) = conflict { | ||
| 763 | assert_eq!(status, EditStatus::Conflict(kind), "{name}"); | ||
| 764 | assert_eq!(server.publications, old_publications); | ||
| 765 | assert_eq!(cache.pending().unwrap(), queue); | ||
| 766 | assert!(cache.snapshot().unwrap() == local); | ||
| 767 | drop(cache); | ||
| 768 | let cache = Replica::open(&path).unwrap(); | ||
| 769 | assert_eq!(cache.status(id).unwrap(), Some(status)); | ||
| 770 | if kind == ConflictKind::TargetUnavailable { | ||
| 771 | counts[2] += 1; | ||
| 772 | assert!( | ||
| 773 | cache | ||
| 774 | .rebase_tree_conflict(id, &local, &server.visible) | ||
| 775 | .is_err() | ||
| 776 | ); | ||
| 777 | records.push(serde_json::json!({"page": name, "space": sid, "result": "retained"})); | ||
| 778 | continue; | ||
| 779 | } | ||
| 780 | counts[1] += 1; | ||
| 781 | cache | ||
| 782 | .rebase_tree_conflict(id, &local, &server.visible) | ||
| 783 | .unwrap(); | ||
| 784 | assert_eq!(cache.pending().unwrap()[1], queue[1]); | ||
| 785 | for expected in [id, dependent] { | ||
| 786 | assert!( | ||
| 787 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) | ||
| 788 | ); | ||
| 789 | } | ||
| 790 | } else { | ||
| 791 | counts[0] += 1; | ||
| 792 | assert!(matches!(status, EditStatus::Published { .. }), "{name}"); | ||
| 793 | assert_eq!(server.publications, old_publications); | ||
| 794 | assert!( | ||
| 795 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == dependent) | ||
| 796 | ); | ||
| 797 | } | ||
| 798 | assert_eq!( | ||
| 799 | super::outline::node(&server.durable, sid, dependent_text)["kind"]["text"], | ||
| 800 | format!( | ||
| 801 | "Offline {}", | ||
| 802 | match &view.nodes[&dependent_text].kind { | ||
| 803 | Kind::RichText { text, .. } => text, | ||
| 804 | _ => panic!(), | ||
| 805 | } | ||
| 806 | ) | ||
| 807 | ); | ||
| 808 | records.push(serde_json::json!({"page": name, "space": sid, "result": if conflict.is_some() { "reviewed" } else { "converged" }})); | ||
| 809 | } | ||
| 810 | assert_eq!(counts, [3, 7, 4]); | ||
| 811 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") { | ||
| 812 | let output = std::path::PathBuf::from(output); | ||
| 813 | std::fs::create_dir_all(output.join("candidate")).unwrap(); | ||
| 814 | std::fs::write(output.join("candidate/synthetic.one"), &server.durable).unwrap(); | ||
| 815 | std::fs::write( | ||
| 816 | output.join("manifest.json"), | ||
| 817 | serde_json::to_vec_pretty(&records).unwrap(), | ||
| 818 | ) | ||
| 819 | .unwrap(); | ||
| 820 | } | ||
| 821 | } | ||
crates/onestore-smb/Cargo.toml deleted-13| ... | @@ -1,13 +0,0 @@ | ||
| 1 | [package] | ||
| 2 | name = "onestore-smb" | ||
| 3 | version = "0.1.0" | ||
| 4 | edition = "2024" | ||
| 5 | publish = false | ||
| 6 | |||
| 7 | [dependencies] | ||
| 8 | onestore = { path = "../onestore" } | ||
| 9 | smb2 = "=0.21.0" | ||
| 10 | tokio = { version = "1", features = ["rt-multi-thread", "time"] } | ||
| 11 | |||
| 12 | [dev-dependencies] | ||
| 13 | serde_json = "1" | ||
crates/onestore-smb/README.md deleted-63| ... | @@ -1,63 +0,0 @@ | ||
| 1 | # onestore-smb | ||
| 2 | |||
| 3 | Optional blocking SMB access for OneNote sections and table-of-contents files. | ||
| 4 | The core `onestore` crate remains independent of network runtimes. This is an | ||
| 5 | experimental Rust API with native interoperability evidence in the repository's | ||
| 6 | [Milestone 9](../../evidence/MILESTONE9.md). | ||
| 7 | |||
| 8 | ```no_run | ||
| 9 | use onestore_smb::{Client, Credentials}; | ||
| 10 | use std::time::Duration; | ||
| 11 | |||
| 12 | let client = Client::connect( | ||
| 13 | "server:445", | ||
| 14 | "notes", | ||
| 15 | Credentials { username: "user", password: "password", domain: "" }, | ||
| 16 | Duration::from_secs(5), | ||
| 17 | )?; | ||
| 18 | let snapshot = client.read("Personal/Video.one", 64 * 1024 * 1024)?; | ||
| 19 | let store = onestore::Store::parse(&snapshot)?; | ||
| 20 | let revisions = onestore::RevisionIndex::parse(&store)?; | ||
| 21 | let document = onestore::document::Document::parse(&revisions)?; | ||
| 22 | # Ok::<(), Box<dyn std::error::Error>>(()) | ||
| 23 | ``` | ||
| 24 | |||
| 25 | Paths are relative to the share. The read limit bounds the complete physical | ||
| 26 | snapshot. Call from a background thread outside a Tokio runtime. Use identities | ||
| 27 | from the document and the same snapshot with `Client::commit_text` or | ||
| 28 | `Client::commit_property_bytes`; their errors retain `onestore::CommitState`. | ||
| 29 | `PreparedEdit::{text,insert,format}` separate preparation from I/O: inspect the immutable image | ||
| 30 | and persist the intended revision identity before `Client::commit_prepared`. | ||
| 31 | `Client::confirm_snapshot` compares and flushes an observed image, then refreshes | ||
| 32 | its header version metadata without adding a revision. The caller must first | ||
| 33 | establish which intents that image contains and reread before another commit. | ||
| 34 | |||
| 35 | Readers use shared native guards while writers publish under native write-open | ||
| 36 | and byte-lock exclusion. Maintenance is excluded during each operation; pathname | ||
| 37 | identity is checked after acquiring the guards. Connection loss retires the | ||
| 38 | client. Reconnect for subsequent operations, and reconcile an `Unknown` edit | ||
| 39 | before retrying it. The transport does not automatically replay requests. | ||
| 40 | |||
| 41 | `Client::read_dir(path, entry_limit)` enumerates a directory, including the share | ||
| 42 | root with an empty path. It follows every response page and returns no partial | ||
| 43 | list on interruption, entry-limit overflow or close failure. Entries retain exact | ||
| 44 | Unicode names, observed sizes and MS-FSCC attributes, including directory/reparse | ||
| 45 | flags. Concurrent directory changes are not an atomic snapshot; repeated names | ||
| 46 | are rejected with `ResourceBusy`. Notebook identities come from the files, not | ||
| 47 | directory names or sizes. Missing paths, denied access and non-directory paths | ||
| 48 | have distinct I/O error kinds. | ||
| 49 | |||
| 50 | `Client::read_asset(path, byte_limit)` reads an external payload under a read-only | ||
| 51 | share handle that excludes writes and deletion. Empty files succeed; limits, | ||
| 52 | interrupted reads and failed close never return partial bytes. This payload read | ||
| 53 | does not parse a OneStore header or acquire its reader-coordination bytes. | ||
| 54 | |||
| 55 | `python3 tools/test_smb_directory.py VM OUTPUT` checks a caller-owned disposable | ||
| 56 | Linux lab VM against its filesystem listing and interrupts directory requests, | ||
| 57 | responses and close. It creates synthetic files in that VM; the caller retains | ||
| 58 | responsibility for VM teardown. The parser also has bounded-record/truncation | ||
| 59 | tests independent of the server. | ||
| 60 | |||
| 61 | Device and simulator builds link for iOS. Native acceptance uses disposable | ||
| 62 | OneNote 2010 clients and Samba; it does not establish on-device execution or | ||
| 63 | physical power-loss durability. | ||
crates/onestore-smb/examples/smb_concurrent_client.rs deleted-22| ... | @@ -1,22 +0,0 @@ | ||
| 1 | #[path = "../../onestore/examples/support/concurrent.rs"] | ||
| 2 | mod concurrent; | ||
| 3 | |||
| 4 | use onestore_smb::{Client, Credentials}; | ||
| 5 | use std::{env, time::Duration}; | ||
| 6 | |||
| 7 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 8 | let client = Client::connect( | ||
| 9 | &env::var("ONESTORE_SMB_LAB")?, | ||
| 10 | &env::var("ONESTORE_SMB_SHARE")?, | ||
| 11 | Credentials::default(), | ||
| 12 | Duration::from_secs(10), | ||
| 13 | )?; | ||
| 14 | let args: Vec<_> = env::args().skip(1).collect(); | ||
| 15 | concurrent::run( | ||
| 16 | &args, | ||
| 17 | |path| client.read(path, 256 * 1024 * 1024), | ||
| 18 | |path, source, space, object, range, replacement| { | ||
| 19 | client.commit_text(path, source, space, object, range, replacement) | ||
| 20 | }, | ||
| 21 | ) | ||
| 22 | } | ||
crates/onestore-smb/examples/smb_reconnect_client.rs deleted-267| ... | @@ -1,267 +0,0 @@ | ||
| 1 | #[path = "../../onestore/examples/support/concurrent.rs"] | ||
| 2 | mod concurrent; | ||
| 3 | |||
| 4 | use onestore::{ | ||
| 5 | CommitError, CommitState, ExGuid, RevisionIndex, Store, | ||
| 6 | document::{Document, Kind}, | ||
| 7 | }; | ||
| 8 | use onestore_smb::{Client, Credentials}; | ||
| 9 | use serde_json::json; | ||
| 10 | use std::{ | ||
| 11 | cell::RefCell, | ||
| 12 | env, io, thread, | ||
| 13 | time::{Duration, Instant, SystemTime, UNIX_EPOCH}, | ||
| 14 | }; | ||
| 15 | |||
| 16 | fn paragraph( | ||
| 17 | bytes: &[u8], | ||
| 18 | space: ExGuid, | ||
| 19 | object: ExGuid, | ||
| 20 | ) -> Result<String, Box<dyn std::error::Error>> { | ||
| 21 | let store = Store::parse(bytes)?; | ||
| 22 | let index = RevisionIndex::parse(&store)?; | ||
| 23 | index.validate_current()?; | ||
| 24 | let document = Document::parse(&index)?; | ||
| 25 | let space = &document.spaces[&space]; | ||
| 26 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 27 | let Kind::RichText { text, .. } = &revision.nodes[&object].kind else { | ||
| 28 | return Err("The append target is no longer text.".into()); | ||
| 29 | }; | ||
| 30 | Ok(text.clone()) | ||
| 31 | } | ||
| 32 | |||
| 33 | // Only the owned append workload guarantees unique tokens that no writer removes. | ||
| 34 | fn retained(before: &str, token: &str, current: &str, state: CommitState) -> io::Result<bool> { | ||
| 35 | let count = current.matches(token).count(); | ||
| 36 | if count == 0 && state != CommitState::Committed && current.starts_with(before) { | ||
| 37 | return Ok(false); | ||
| 38 | } | ||
| 39 | if count == 1 | ||
| 40 | && state != CommitState::NotCommitted | ||
| 41 | && current.starts_with(&format!("{before}{token}")) | ||
| 42 | { | ||
| 43 | return Ok(true); | ||
| 44 | } | ||
| 45 | Err(io::Error::other( | ||
| 46 | "The append history contradicts the commit outcome.", | ||
| 47 | )) | ||
| 48 | } | ||
| 49 | |||
| 50 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 51 | let args: Vec<_> = env::args().skip(1).collect(); | ||
| 52 | if args | ||
| 53 | .first() | ||
| 54 | .is_none_or(|mode| !["read", "write"].contains(&mode.as_str())) | ||
| 55 | { | ||
| 56 | return Err("Reconnect testing requires the append-only workload.".into()); | ||
| 57 | } | ||
| 58 | let address = env::var("ONESTORE_SMB_LAB")?; | ||
| 59 | let share = env::var("ONESTORE_SMB_SHARE")?; | ||
| 60 | let client = RefCell::new(Some(Client::connect( | ||
| 61 | &address, | ||
| 62 | &share, | ||
| 63 | Credentials::default(), | ||
| 64 | Duration::from_secs(5), | ||
| 65 | )?)); | ||
| 66 | let read = |path: &str| { | ||
| 67 | let mut session = client.borrow_mut(); | ||
| 68 | if session.is_none() { | ||
| 69 | match Client::connect( | ||
| 70 | &address, | ||
| 71 | &share, | ||
| 72 | Credentials::default(), | ||
| 73 | Duration::from_secs(5), | ||
| 74 | ) { | ||
| 75 | Ok(fresh) => { | ||
| 76 | *session = Some(fresh); | ||
| 77 | println!( | ||
| 78 | "{}", | ||
| 79 | json!({"event":"transport_connected", "at_us":SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_micros()}) | ||
| 80 | ); | ||
| 81 | } | ||
| 82 | Err(error) => { | ||
| 83 | println!( | ||
| 84 | "{}", | ||
| 85 | json!({"event":"transport_connect_error","error":error.to_string()}) | ||
| 86 | ); | ||
| 87 | return Err(io::ErrorKind::WouldBlock.into()); | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | let started = SystemTime::now() | ||
| 92 | .duration_since(UNIX_EPOCH) | ||
| 93 | .unwrap() | ||
| 94 | .as_micros(); | ||
| 95 | match session.as_ref().unwrap().read(path, 256 * 1024 * 1024) { | ||
| 96 | Ok(bytes) => { | ||
| 97 | if args.get(2).is_some_and(|actor| actor == "r0") | ||
| 98 | && let Some(folder) = env::var_os("ONESTORE_OFFLINE_FORMAT_REPLY_DIR") | ||
| 99 | { | ||
| 100 | let folder = std::path::PathBuf::from(folder); | ||
| 101 | let captured = folder.join("offline-retired.one"); | ||
| 102 | if !captured.exists() | ||
| 103 | && let Ok(marker) = std::fs::read(folder.join("offline-paused-w0.isolate")) | ||
| 104 | && let Ok(watched) = serde_json::from_slice::<serde_json::Value>(&marker) | ||
| 105 | && watched["after_us"] | ||
| 106 | .as_u64() | ||
| 107 | .is_some_and(|after| started > u128::from(after)) | ||
| 108 | { | ||
| 109 | let sid: ExGuid = watched["space"] | ||
| 110 | .as_str() | ||
| 111 | .ok_or_else(|| io::Error::other("Missing watched space"))? | ||
| 112 | .parse() | ||
| 113 | .map_err(io::Error::other)?; | ||
| 114 | let rid: ExGuid = watched["revision"] | ||
| 115 | .as_str() | ||
| 116 | .ok_or_else(|| io::Error::other("Missing watched revision"))? | ||
| 117 | .parse() | ||
| 118 | .map_err(io::Error::other)?; | ||
| 119 | let store = Store::parse(&bytes).map_err(io::Error::other)?; | ||
| 120 | let index = RevisionIndex::parse(&store).map_err(io::Error::other)?; | ||
| 121 | if index | ||
| 122 | .spaces | ||
| 123 | .get(&sid) | ||
| 124 | .is_some_and(|space| !space.revisions.contains_key(&rid)) | ||
| 125 | { | ||
| 126 | index.validate_current().map_err(io::Error::other)?; | ||
| 127 | std::fs::write(captured.with_extension("tmp"), &bytes)?; | ||
| 128 | std::fs::rename(captured.with_extension("tmp"), captured)?; | ||
| 129 | println!( | ||
| 130 | "{}", | ||
| 131 | json!({"event":"revision_retired", "space":sid.to_string(), "revision":rid.to_string(), "started_us":started, "finished_us":SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_micros()}) | ||
| 132 | ); | ||
| 133 | } | ||
| 134 | } | ||
| 135 | } | ||
| 136 | Ok(bytes) | ||
| 137 | } | ||
| 138 | Err(error) | ||
| 139 | if matches!( | ||
| 140 | error.kind(), | ||
| 141 | io::ErrorKind::Other | io::ErrorKind::TimedOut | io::ErrorKind::NotConnected | ||
| 142 | ) => | ||
| 143 | { | ||
| 144 | println!( | ||
| 145 | "{}", | ||
| 146 | json!({"event":"transport_read_error","error":error.to_string()}) | ||
| 147 | ); | ||
| 148 | *session = None; | ||
| 149 | Err(io::ErrorKind::WouldBlock.into()) | ||
| 150 | } | ||
| 151 | result => result, | ||
| 152 | } | ||
| 153 | }; | ||
| 154 | concurrent::run( | ||
| 155 | &args, | ||
| 156 | read, | ||
| 157 | |path, source, space, object, range, replacement| { | ||
| 158 | let outcome = client.borrow().as_ref().unwrap().commit_text( | ||
| 159 | path, | ||
| 160 | source, | ||
| 161 | space, | ||
| 162 | object, | ||
| 163 | range, | ||
| 164 | replacement, | ||
| 165 | ); | ||
| 166 | let Err(error) = outcome else { | ||
| 167 | return Ok(()); | ||
| 168 | }; | ||
| 169 | if error.state == CommitState::NotCommitted | ||
| 170 | && matches!( | ||
| 171 | error.error.kind(), | ||
| 172 | io::ErrorKind::WouldBlock | ||
| 173 | | io::ErrorKind::ResourceBusy | ||
| 174 | | io::ErrorKind::PermissionDenied | ||
| 175 | | io::ErrorKind::NotFound | ||
| 176 | ) | ||
| 177 | { | ||
| 178 | return Err(error); | ||
| 179 | } | ||
| 180 | println!( | ||
| 181 | "{}", | ||
| 182 | json!({"event":"transport_commit_error","state":format!("{:?}",error.state),"token":replacement,"error":error.error.to_string()}) | ||
| 183 | ); | ||
| 184 | *client.borrow_mut() = None; | ||
| 185 | let deadline = Instant::now() + Duration::from_secs(60); | ||
| 186 | loop { | ||
| 187 | if Instant::now() >= deadline { | ||
| 188 | return Err(error); | ||
| 189 | } | ||
| 190 | let current = match read(path) { | ||
| 191 | Ok(bytes) => bytes, | ||
| 192 | Err(retry) if retry.kind() == io::ErrorKind::WouldBlock => { | ||
| 193 | thread::sleep(Duration::from_millis(100)); | ||
| 194 | continue; | ||
| 195 | } | ||
| 196 | Err(_) => return Err(error), | ||
| 197 | }; | ||
| 198 | let published = paragraph(source, space, object) | ||
| 199 | .and_then(|before| { | ||
| 200 | Ok(retained( | ||
| 201 | &before, | ||
| 202 | replacement, | ||
| 203 | &paragraph(&current, space, object)?, | ||
| 204 | error.state, | ||
| 205 | )?) | ||
| 206 | }) | ||
| 207 | .map_err(|failure| CommitError { | ||
| 208 | state: error.state, | ||
| 209 | error: io::Error::other(failure.to_string()), | ||
| 210 | })?; | ||
| 211 | if published { | ||
| 212 | let confirmation = client.borrow().as_ref().unwrap().commit_text( | ||
| 213 | path, | ||
| 214 | &current, | ||
| 215 | space, | ||
| 216 | object, | ||
| 217 | 0..0, | ||
| 218 | "", | ||
| 219 | ); | ||
| 220 | if let Err(failure) = confirmation | ||
| 221 | && failure.state != CommitState::Committed | ||
| 222 | { | ||
| 223 | println!( | ||
| 224 | "{}", | ||
| 225 | json!({"event":"transport_confirmation_error","state":format!("{:?}", failure.state),"error":failure.error.to_string()}) | ||
| 226 | ); | ||
| 227 | *client.borrow_mut() = None; | ||
| 228 | thread::sleep(Duration::from_millis(100)); | ||
| 229 | continue; | ||
| 230 | } | ||
| 231 | println!( | ||
| 232 | "{}", | ||
| 233 | json!({"event":"transport_reconciled","token":replacement,"published":true,"flush_confirmed":true}) | ||
| 234 | ); | ||
| 235 | return Ok(()); | ||
| 236 | } | ||
| 237 | println!( | ||
| 238 | "{}", | ||
| 239 | json!({"event":"transport_reconciled","token":replacement,"published":false}) | ||
| 240 | ); | ||
| 241 | return Err(CommitError { | ||
| 242 | state: CommitState::NotCommitted, | ||
| 243 | error: io::ErrorKind::ResourceBusy.into(), | ||
| 244 | }); | ||
| 245 | } | ||
| 246 | }, | ||
| 247 | ) | ||
| 248 | } | ||
| 249 | |||
| 250 | #[test] | ||
| 251 | fn uncertain_append_requires_one_retained_token_and_its_predecessor() { | ||
| 252 | for state in [CommitState::Unknown, CommitState::Committed] { | ||
| 253 | assert!(retained("before", " [w0:0]", "before [w0:0] [w1:0]", state).unwrap()); | ||
| 254 | } | ||
| 255 | for state in [CommitState::Unknown, CommitState::NotCommitted] { | ||
| 256 | assert!(!retained("before", " [w0:0]", "before [w1:0]", state).unwrap()); | ||
| 257 | } | ||
| 258 | for (current, state) in [ | ||
| 259 | ("before [w0:0] [w0:0]", CommitState::Unknown), | ||
| 260 | ("changed [w0:0]", CommitState::Unknown), | ||
| 261 | ("befor", CommitState::Unknown), | ||
| 262 | ("before", CommitState::Committed), | ||
| 263 | ("before [w0:0]", CommitState::NotCommitted), | ||
| 264 | ] { | ||
| 265 | assert!(retained("before", " [w0:0]", current, state).is_err()); | ||
| 266 | } | ||
| 267 | } | ||
crates/onestore-smb/src/directory.rs deleted-219| ... | @@ -1,219 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use smb2::msg::query_directory::{ | ||
| 3 | FileInformationClass, QueryDirectoryFlags, QueryDirectoryRequest, QueryDirectoryResponse, | ||
| 4 | }; | ||
| 5 | use std::collections::BTreeMap; | ||
| 6 | |||
| 7 | /// Observed directory metadata, not a stable notebook identity or a file snapshot. | ||
| 8 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 9 | pub struct DirectoryEntry { | ||
| 10 | pub name: String, | ||
| 11 | pub size: u64, | ||
| 12 | /// MS-FSCC file attributes; directory is 0x10 and reparse point is 0x400. | ||
| 13 | pub attributes: u32, | ||
| 14 | } | ||
| 15 | |||
| 16 | impl Client { | ||
| 17 | /// Enumerates a share-relative directory completely or returns an error without a partial list. | ||
| 18 | /// An empty path selects the share root. The limit excludes `.` and `..`. | ||
| 19 | /// Concurrent directory changes are not an atomic snapshot; repeated names return ResourceBusy. | ||
| 20 | pub fn read_dir(&self, path: &str, limit: usize) -> io::Result<Vec<DirectoryEntry>> { | ||
| 21 | if path.contains('\0') || path.encode_utf16().count() > 32767 { | ||
| 22 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 23 | } | ||
| 24 | let response: CreateResponse = self.request( | ||
| 25 | Command::Create, | ||
| 26 | CreateRequest { | ||
| 27 | requested_oplock_level: OplockLevel::None, | ||
| 28 | impersonation_level: ImpersonationLevel::Impersonation, | ||
| 29 | desired_access: FileAccessMask::new(0x80000000), | ||
| 30 | file_attributes: 0, | ||
| 31 | share_access: ShareAccess(7), | ||
| 32 | create_disposition: CreateDisposition::FileOpen, | ||
| 33 | create_options: 0x1, | ||
| 34 | name: smb2::encode_path(&path.replace('\\', "/")), | ||
| 35 | create_contexts: Vec::new(), | ||
| 36 | }, | ||
| 37 | )?; | ||
| 38 | let file = File { | ||
| 39 | client: self, | ||
| 40 | id: Some(response.file_id), | ||
| 41 | }; | ||
| 42 | let mut entries = BTreeMap::new(); | ||
| 43 | loop { | ||
| 44 | let response: io::Result<QueryDirectoryResponse> = | ||
| 45 | self.request_with(Command::QueryDirectory, |connection| { | ||
| 46 | ( | ||
| 47 | QueryDirectoryRequest { | ||
| 48 | file_information_class: FileInformationClass::FileDirectoryInformation, | ||
| 49 | flags: QueryDirectoryFlags(if entries.is_empty() { 1 } else { 0 }), | ||
| 50 | file_index: 0, | ||
| 51 | file_id: file.id.unwrap(), | ||
| 52 | output_buffer_length: connection | ||
| 53 | .params() | ||
| 54 | .expect("connected SMB session is negotiated") | ||
| 55 | .max_transact_size | ||
| 56 | .min(65536), | ||
| 57 | file_name: "*".into(), | ||
| 58 | }, | ||
| 59 | CreditCharge(1), | ||
| 60 | ) | ||
| 61 | }); | ||
| 62 | let response = match response { | ||
| 63 | Ok(response) => response, | ||
| 64 | Err(error) | ||
| 65 | if matches!( | ||
| 66 | error.get_ref().and_then(|error| error.downcast_ref::<smb2::Error>()), | ||
| 67 | Some(smb2::Error::Protocol { status, command: Command::QueryDirectory }) | ||
| 68 | if status.0 == 0x80000006 || (entries.is_empty() && status.0 == 0xc000000f) | ||
| 69 | ) => | ||
| 70 | { | ||
| 71 | break; | ||
| 72 | } | ||
| 73 | Err(error) => return Err(error), | ||
| 74 | }; | ||
| 75 | for entry in decode(&response.output_buffer)? { | ||
| 76 | if entries | ||
| 77 | .insert(entry.name, (entry.size, entry.attributes)) | ||
| 78 | .is_some() | ||
| 79 | { | ||
| 80 | return Err(io::ErrorKind::ResourceBusy.into()); | ||
| 81 | } | ||
| 82 | if entries.len() | ||
| 83 | - usize::from(entries.contains_key(".")) | ||
| 84 | - usize::from(entries.contains_key("..")) | ||
| 85 | > limit | ||
| 86 | { | ||
| 87 | return Err(io::ErrorKind::FileTooLarge.into()); | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | file.close()?; | ||
| 92 | Ok(entries | ||
| 93 | .into_iter() | ||
| 94 | .filter(|(name, _)| name != "." && name != "..") | ||
| 95 | .map(|(name, (size, attributes))| DirectoryEntry { | ||
| 96 | name, | ||
| 97 | size, | ||
| 98 | attributes, | ||
| 99 | }) | ||
| 100 | .collect()) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | fn decode(mut bytes: &[u8]) -> io::Result<Vec<DirectoryEntry>> { | ||
| 105 | if bytes.len() > 65536 { | ||
| 106 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 107 | } | ||
| 108 | let mut entries = Vec::new(); | ||
| 109 | loop { | ||
| 110 | if bytes.len() < 64 { | ||
| 111 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 112 | } | ||
| 113 | let next = u32::from_le_bytes(bytes[..4].try_into().unwrap()) as usize; | ||
| 114 | let length = u32::from_le_bytes(bytes[60..64].try_into().unwrap()) as usize; | ||
| 115 | let end = 64usize | ||
| 116 | .checked_add(length) | ||
| 117 | .ok_or(io::ErrorKind::InvalidData)?; | ||
| 118 | if length == 0 | ||
| 119 | || !length.is_multiple_of(2) | ||
| 120 | || end > bytes.len() | ||
| 121 | || (next != 0 && (next < end || !next.is_multiple_of(8) || next >= bytes.len())) | ||
| 122 | || (next == 0 && bytes.len() - end > 7) | ||
| 123 | { | ||
| 124 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 125 | } | ||
| 126 | let units: Vec<_> = bytes[64..end] | ||
| 127 | .chunks_exact(2) | ||
| 128 | .map(|unit| u16::from_le_bytes([unit[0], unit[1]])) | ||
| 129 | .collect(); | ||
| 130 | let name = String::from_utf16(&units).map_err(|_| io::ErrorKind::InvalidData)?; | ||
| 131 | if name.contains(['\0', '/', '\\']) { | ||
| 132 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 133 | } | ||
| 134 | let size = i64::from_le_bytes(bytes[40..48].try_into().unwrap()); | ||
| 135 | entries.push(DirectoryEntry { | ||
| 136 | name, | ||
| 137 | size: size.try_into().map_err(|_| io::ErrorKind::InvalidData)?, | ||
| 138 | attributes: u32::from_le_bytes(bytes[56..60].try_into().unwrap()), | ||
| 139 | }); | ||
| 140 | if next == 0 { | ||
| 141 | return Ok(entries); | ||
| 142 | } | ||
| 143 | bytes = &bytes[next..]; | ||
| 144 | } | ||
| 145 | } | ||
| 146 | |||
| 147 | #[cfg(test)] | ||
| 148 | mod tests { | ||
| 149 | use super::*; | ||
| 150 | |||
| 151 | fn record(name: &str, size: u64, attributes: u32) -> Vec<u8> { | ||
| 152 | let mut bytes = vec![0; 64]; | ||
| 153 | bytes[40..48].copy_from_slice(&size.to_le_bytes()); | ||
| 154 | bytes[56..60].copy_from_slice(&attributes.to_le_bytes()); | ||
| 155 | let name: Vec<_> = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); | ||
| 156 | bytes[60..64].copy_from_slice(&(name.len() as u32).to_le_bytes()); | ||
| 157 | bytes.extend(name); | ||
| 158 | bytes | ||
| 159 | } | ||
| 160 | |||
| 161 | #[test] | ||
| 162 | fn directory_records_preserve_names_sizes_and_attributes() { | ||
| 163 | let mut bytes = Vec::new(); | ||
| 164 | let mut expected = Vec::new(); | ||
| 165 | for i in 0..300u32 { | ||
| 166 | let name = format!("Section {i} 🦀 e\u{301}.one"); | ||
| 167 | let size = u64::from(i) * 100_000_000; | ||
| 168 | let attributes = if i % 3 == 0 { 0x410 } else { 0x20 }; | ||
| 169 | let mut entry = record(&name, size, attributes); | ||
| 170 | if i != 299 { | ||
| 171 | entry.resize(entry.len().next_multiple_of(8), 0xa5); | ||
| 172 | let length = entry.len() as u32; | ||
| 173 | entry[..4].copy_from_slice(&length.to_le_bytes()); | ||
| 174 | } | ||
| 175 | bytes.extend(entry); | ||
| 176 | expected.push(DirectoryEntry { | ||
| 177 | name, | ||
| 178 | size, | ||
| 179 | attributes, | ||
| 180 | }); | ||
| 181 | } | ||
| 182 | assert_eq!(decode(&bytes).unwrap(), expected); | ||
| 183 | for end in 0..bytes.len() { | ||
| 184 | assert!(decode(&bytes[..end]).is_err(), "accepted prefix {end}"); | ||
| 185 | } | ||
| 186 | } | ||
| 187 | |||
| 188 | #[test] | ||
| 189 | fn invalid_directory_records_never_become_partial_results() { | ||
| 190 | assert!(decode(&vec![0; 65537]).is_err()); | ||
| 191 | for name in ["", "bad\0name", "a/b", "a\\b"] { | ||
| 192 | assert!(decode(&record(name, 0, 0)).is_err()); | ||
| 193 | } | ||
| 194 | let valid = record("a.one", 12, 0x20); | ||
| 195 | for (at, value) in [ | ||
| 196 | (0, 8), | ||
| 197 | (0, 65), | ||
| 198 | (0, 72), | ||
| 199 | (0, u32::MAX), | ||
| 200 | (60, 0), | ||
| 201 | (60, 1), | ||
| 202 | (60, u32::MAX), | ||
| 203 | (44, u32::MAX), | ||
| 204 | ] { | ||
| 205 | let mut bytes = valid.clone(); | ||
| 206 | bytes[at..at + 4].copy_from_slice(&value.to_le_bytes()); | ||
| 207 | assert!(decode(&bytes).is_err(), "offset={at} value={value}"); | ||
| 208 | } | ||
| 209 | let mut bytes = valid.clone(); | ||
| 210 | bytes[64..66].copy_from_slice(&0xd800u16.to_le_bytes()); | ||
| 211 | assert!(decode(&bytes).is_err()); | ||
| 212 | let mut bytes = valid; | ||
| 213 | bytes.extend_from_slice(&[0; 8]); | ||
| 214 | assert!(decode(&bytes).is_err()); | ||
| 215 | for name in [".", ".."] { | ||
| 216 | assert_eq!(decode(&record(name, 0, 0x10)).unwrap()[0].name, name); | ||
| 217 | } | ||
| 218 | } | ||
| 219 | } | ||
crates/onestore-smb/src/lib.rs deleted-517| ... | @@ -1,517 +0,0 @@ | ||
| 1 | #![forbid(unsafe_code)] | ||
| 2 | #![doc = include_str!("../README.md")] | ||
| 3 | |||
| 4 | use onestore::{CommitError, CommitIo, CommitState, ExGuid}; | ||
| 5 | use smb2::{ | ||
| 6 | Session, Tree, | ||
| 7 | client::connection::{Connection, NegotiatedParams}, | ||
| 8 | msg::{ | ||
| 9 | close::{CloseRequest, CloseResponse}, | ||
| 10 | create::{ | ||
| 11 | CreateDisposition, CreateRequest, CreateResponse, ImpersonationLevel, ShareAccess, | ||
| 12 | }, | ||
| 13 | flush::{FlushRequest, FlushResponse}, | ||
| 14 | lock::{LockElement, LockRequest, LockResponse}, | ||
| 15 | query_info::{InfoType, QueryInfoRequest, QueryInfoResponse}, | ||
| 16 | read::{ReadRequest, ReadResponse}, | ||
| 17 | write::{WriteRequest, WriteResponse}, | ||
| 18 | }, | ||
| 19 | pack::{Pack, ReadCursor, Unpack}, | ||
| 20 | types::{ | ||
| 21 | Command, CreditCharge, Dialect, FileId, OplockLevel, | ||
| 22 | flags::{Capabilities, FileAccessMask}, | ||
| 23 | }, | ||
| 24 | }; | ||
| 25 | use std::{io, ops::Range, sync::Mutex, time::Duration}; | ||
| 26 | use tokio::runtime::{Handle, Runtime}; | ||
| 27 | |||
| 28 | mod directory; | ||
| 29 | pub use directory::DirectoryEntry; | ||
| 30 | |||
| 31 | #[derive(Default)] | ||
| 32 | pub struct Credentials<'a> { | ||
| 33 | pub username: &'a str, | ||
| 34 | pub password: &'a str, | ||
| 35 | pub domain: &'a str, | ||
| 36 | } | ||
| 37 | |||
| 38 | /// Blocking connection with no automatic request replay or cached file contents. | ||
| 39 | /// Call from a background thread outside a Tokio runtime. | ||
| 40 | /// Paths are relative to the share; both `/` and `\` are separators. | ||
| 41 | pub struct Client { | ||
| 42 | connection: Mutex<Option<Connection>>, | ||
| 43 | tree: Tree, | ||
| 44 | timeout: Duration, | ||
| 45 | runtime: Mutex<Option<Runtime>>, | ||
| 46 | } | ||
| 47 | |||
| 48 | impl Client { | ||
| 49 | pub fn connect( | ||
| 50 | address: &str, | ||
| 51 | share: &str, | ||
| 52 | credentials: Credentials<'_>, | ||
| 53 | timeout: Duration, | ||
| 54 | ) -> io::Result<Self> { | ||
| 55 | if Handle::try_current().is_ok() || timeout.is_zero() { | ||
| 56 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 57 | } | ||
| 58 | let runtime = tokio::runtime::Builder::new_multi_thread() | ||
| 59 | .worker_threads(1) | ||
| 60 | .enable_all() | ||
| 61 | .build()?; | ||
| 62 | let (connection, tree) = runtime | ||
| 63 | .block_on(async { | ||
| 64 | tokio::time::timeout(timeout, async { | ||
| 65 | let mut connection = Connection::connect(address, timeout).await?; | ||
| 66 | connection.set_compression_requested(false); | ||
| 67 | connection.negotiate().await?; | ||
| 68 | Session::setup( | ||
| 69 | &mut connection, | ||
| 70 | credentials.username, | ||
| 71 | credentials.password, | ||
| 72 | credentials.domain, | ||
| 73 | ) | ||
| 74 | .await?; | ||
| 75 | let tree = Tree::connect(&mut connection, share).await?; | ||
| 76 | Ok::<_, smb2::Error>((connection, tree)) | ||
| 77 | }) | ||
| 78 | .await | ||
| 79 | }) | ||
| 80 | .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))? | ||
| 81 | .map_err(io::Error::other)?; | ||
| 82 | Ok(Self { | ||
| 83 | connection: Mutex::new(Some(connection)), | ||
| 84 | tree, | ||
| 85 | timeout, | ||
| 86 | runtime: Mutex::new(Some(runtime)), | ||
| 87 | }) | ||
| 88 | } | ||
| 89 | |||
| 90 | fn retire(&self) { | ||
| 91 | if let Some(connection) = self | ||
| 92 | .connection | ||
| 93 | .lock() | ||
| 94 | .unwrap_or_else(|error| error.into_inner()) | ||
| 95 | .take() | ||
| 96 | { | ||
| 97 | connection.mark_dead(); | ||
| 98 | } | ||
| 99 | if let Some(runtime) = self | ||
| 100 | .runtime | ||
| 101 | .lock() | ||
| 102 | .unwrap_or_else(|error| error.into_inner()) | ||
| 103 | .take() | ||
| 104 | { | ||
| 105 | runtime.shutdown_background(); | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | fn request<T: Unpack>(&self, command: Command, body: impl Pack) -> io::Result<T> { | ||
| 110 | self.request_with(command, |_| (body, CreditCharge(1))) | ||
| 111 | } | ||
| 112 | |||
| 113 | fn request_with<T: Unpack, B: Pack>( | ||
| 114 | &self, | ||
| 115 | command: Command, | ||
| 116 | prepare: impl FnOnce(&Connection) -> (B, CreditCharge), | ||
| 117 | ) -> io::Result<T> { | ||
| 118 | if Handle::try_current().is_ok() { | ||
| 119 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 120 | } | ||
| 121 | let connection = self | ||
| 122 | .connection | ||
| 123 | .lock() | ||
| 124 | .map_err(|_| io::ErrorKind::Other)? | ||
| 125 | .clone() | ||
| 126 | .ok_or(io::ErrorKind::NotConnected)?; | ||
| 127 | let frame = { | ||
| 128 | let runtime = self.runtime.lock().map_err(|_| io::ErrorKind::Other)?; | ||
| 129 | let (body, charge) = prepare(&connection); | ||
| 130 | runtime | ||
| 131 | .as_ref() | ||
| 132 | .ok_or(io::ErrorKind::NotConnected)? | ||
| 133 | .block_on(async { | ||
| 134 | tokio::time::timeout( | ||
| 135 | self.timeout, | ||
| 136 | connection.execute_with_credits( | ||
| 137 | command, | ||
| 138 | &body, | ||
| 139 | Some(self.tree.tree_id), | ||
| 140 | charge, | ||
| 141 | ), | ||
| 142 | ) | ||
| 143 | .await | ||
| 144 | }) | ||
| 145 | }; | ||
| 146 | let frame = frame | ||
| 147 | .map_err(|_| io::Error::from(io::ErrorKind::TimedOut)) | ||
| 148 | .and_then(|result| result.map_err(io::Error::other)) | ||
| 149 | .inspect_err(|_| self.retire())?; | ||
| 150 | if frame.header.command != command { | ||
| 151 | self.retire(); | ||
| 152 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 153 | } | ||
| 154 | if frame.header.status.0 != 0 { | ||
| 155 | let kind = match frame.header.status.0 { | ||
| 156 | 0xc0000043 | 0xc0000054 | 0xc0000055 => io::ErrorKind::WouldBlock, | ||
| 157 | 0xc0000011 => io::ErrorKind::UnexpectedEof, | ||
| 158 | 0xc0000034 | 0xc000003a => io::ErrorKind::NotFound, | ||
| 159 | 0xc0000022 => io::ErrorKind::PermissionDenied, | ||
| 160 | 0xc0000103 => io::ErrorKind::NotADirectory, | ||
| 161 | _ => io::ErrorKind::Other, | ||
| 162 | }; | ||
| 163 | return Err(io::Error::new( | ||
| 164 | kind, | ||
| 165 | smb2::Error::Protocol { | ||
| 166 | status: frame.header.status, | ||
| 167 | command, | ||
| 168 | }, | ||
| 169 | )); | ||
| 170 | } | ||
| 171 | T::unpack(&mut ReadCursor::new(&frame.body)).map_err(|error| { | ||
| 172 | self.retire(); | ||
| 173 | io::Error::new(io::ErrorKind::InvalidData, error) | ||
| 174 | }) | ||
| 175 | } | ||
| 176 | |||
| 177 | fn open(&self, path: &str, write: bool) -> io::Result<File<'_>> { | ||
| 178 | self.open_shared(path, write, if write { 5 } else { 7 }) | ||
| 179 | } | ||
| 180 | |||
| 181 | fn open_shared(&self, path: &str, write: bool, sharing: u32) -> io::Result<File<'_>> { | ||
| 182 | if path.is_empty() || path.contains('\0') || path.encode_utf16().count() > 32767 { | ||
| 183 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 184 | } | ||
| 185 | let response: CreateResponse = self.request( | ||
| 186 | Command::Create, | ||
| 187 | CreateRequest { | ||
| 188 | requested_oplock_level: OplockLevel::None, | ||
| 189 | impersonation_level: ImpersonationLevel::Impersonation, | ||
| 190 | desired_access: FileAccessMask::new(if write { 0xc0000000 } else { 0x80000000 }), | ||
| 191 | file_attributes: 0, | ||
| 192 | share_access: ShareAccess(sharing), | ||
| 193 | create_disposition: CreateDisposition::FileOpen, | ||
| 194 | create_options: 0x42, | ||
| 195 | name: smb2::encode_path(&path.replace('\\', "/")), | ||
| 196 | create_contexts: Vec::new(), | ||
| 197 | }, | ||
| 198 | )?; | ||
| 199 | Ok(File { | ||
| 200 | client: self, | ||
| 201 | id: Some(response.file_id), | ||
| 202 | }) | ||
| 203 | } | ||
| 204 | |||
| 205 | /// Reads a bounded external payload while denying concurrent writes and deletion. | ||
| 206 | /// Empty files succeed; limits, sharing contention and failed close return no payload. | ||
| 207 | pub fn read_asset(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 208 | let mut file = self.open_shared(path, false, 1)?; | ||
| 209 | let mut bytes = Vec::new(); | ||
| 210 | let mut block = [0; 65536]; | ||
| 211 | loop { | ||
| 212 | let count = (limit - bytes.len()).min(block.len() - 1) + 1; | ||
| 213 | let read = file.read_at( | ||
| 214 | u64::try_from(bytes.len()).map_err(|_| io::ErrorKind::InvalidInput)?, | ||
| 215 | &mut block[..count], | ||
| 216 | )?; | ||
| 217 | if read == 0 { | ||
| 218 | break; | ||
| 219 | } | ||
| 220 | bytes.extend_from_slice(&block[..read]); | ||
| 221 | if bytes.len() > limit { | ||
| 222 | return Err(io::ErrorKind::FileTooLarge.into()); | ||
| 223 | } | ||
| 224 | } | ||
| 225 | file.close()?; | ||
| 226 | Ok(bytes) | ||
| 227 | } | ||
| 228 | |||
| 229 | /// Reads one bounded, consistent snapshot; contention returns WouldBlock. | ||
| 230 | pub fn read(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 231 | self.read_with(path, |file| { | ||
| 232 | onestore::read_snapshot(|offset, output| file.read_at(offset, output), limit) | ||
| 233 | }) | ||
| 234 | } | ||
| 235 | |||
| 236 | /// Reads consistent storage, including encrypted or incomplete document graphs. | ||
| 237 | /// Storage validation alone does not establish edit readiness. | ||
| 238 | pub fn read_storage(&self, path: &str, limit: usize) -> io::Result<Vec<u8>> { | ||
| 239 | self.read_with(path, |file| { | ||
| 240 | onestore::read_storage_snapshot(|offset, output| file.read_at(offset, output), limit) | ||
| 241 | }) | ||
| 242 | } | ||
| 243 | |||
| 244 | fn read_with( | ||
| 245 | &self, | ||
| 246 | path: &str, | ||
| 247 | snapshot: impl FnOnce(&mut File<'_>) -> io::Result<Option<Vec<u8>>>, | ||
| 248 | ) -> io::Result<Vec<u8>> { | ||
| 249 | let mut file = self.open(path, false)?.coordinate(path, false)?; | ||
| 250 | let result = snapshot(&mut file) | ||
| 251 | .and_then(|snapshot| snapshot.ok_or_else(|| io::ErrorKind::WouldBlock.into())); | ||
| 252 | let closed = file.close(); | ||
| 253 | let snapshot = result?; | ||
| 254 | closed?; | ||
| 255 | Ok(snapshot) | ||
| 256 | } | ||
| 257 | |||
| 258 | pub fn commit_text( | ||
| 259 | &self, | ||
| 260 | path: &str, | ||
| 261 | source: &[u8], | ||
| 262 | space: ExGuid, | ||
| 263 | object: ExGuid, | ||
| 264 | range: Range<u32>, | ||
| 265 | replacement: &str, | ||
| 266 | ) -> Result<(), CommitError> { | ||
| 267 | self.commit(path, |file| { | ||
| 268 | onestore::commit_text(file, source, space, object, range, replacement) | ||
| 269 | }) | ||
| 270 | } | ||
| 271 | |||
| 272 | pub fn commit_property_bytes( | ||
| 273 | &self, | ||
| 274 | path: &str, | ||
| 275 | source: &[u8], | ||
| 276 | space: ExGuid, | ||
| 277 | object: ExGuid, | ||
| 278 | property: u32, | ||
| 279 | value: &[u8], | ||
| 280 | ) -> Result<(), CommitError> { | ||
| 281 | self.commit(path, |file| { | ||
| 282 | onestore::commit_property_bytes(file, source, space, object, property, value) | ||
| 283 | }) | ||
| 284 | } | ||
| 285 | |||
| 286 | /// Publishes a prepared edit using the same native writer coordination as text commits. | ||
| 287 | pub fn commit_prepared( | ||
| 288 | &self, | ||
| 289 | path: &str, | ||
| 290 | edit: &onestore::PreparedEdit<'_>, | ||
| 291 | ) -> Result<(), CommitError> { | ||
| 292 | self.commit(path, |file| edit.commit(file)) | ||
| 293 | } | ||
| 294 | |||
| 295 | /// Confirms an observed snapshot's durability under native writer coordination. | ||
| 296 | pub fn confirm_snapshot(&self, path: &str, source: &[u8]) -> Result<(), CommitError> { | ||
| 297 | self.commit(path, |file| onestore::confirm_snapshot(file, source)) | ||
| 298 | } | ||
| 299 | |||
| 300 | fn commit( | ||
| 301 | &self, | ||
| 302 | path: &str, | ||
| 303 | operation: impl FnOnce(&mut File<'_>) -> Result<(), CommitError>, | ||
| 304 | ) -> Result<(), CommitError> { | ||
| 305 | let mut file = self | ||
| 306 | .open(path, true) | ||
| 307 | .and_then(|file| file.coordinate(path, true)) | ||
| 308 | .map_err(|error| CommitError { | ||
| 309 | state: CommitState::NotCommitted, | ||
| 310 | error, | ||
| 311 | })?; | ||
| 312 | let result = operation(&mut file); | ||
| 313 | let closed = file.close(); | ||
| 314 | result?; | ||
| 315 | closed.map_err(|error| CommitError { | ||
| 316 | state: CommitState::Committed, | ||
| 317 | error, | ||
| 318 | }) | ||
| 319 | } | ||
| 320 | } | ||
| 321 | |||
| 322 | impl Drop for Client { | ||
| 323 | fn drop(&mut self) { | ||
| 324 | self.retire(); | ||
| 325 | } | ||
| 326 | } | ||
| 327 | |||
| 328 | struct File<'a> { | ||
| 329 | client: &'a Client, | ||
| 330 | id: Option<FileId>, | ||
| 331 | } | ||
| 332 | impl File<'_> { | ||
| 333 | fn coordinate(self, path: &str, write: bool) -> io::Result<Self> { | ||
| 334 | self.lock(0xfffffffb, 0x11)?; | ||
| 335 | if write { | ||
| 336 | self.lock(0xfffffffd, 0x12)?; | ||
| 337 | } | ||
| 338 | let current = self.client.open(path, false)?; | ||
| 339 | let same = self.identity()? == current.identity()?; | ||
| 340 | current.close()?; | ||
| 341 | if !same { | ||
| 342 | return Err(io::ErrorKind::ResourceBusy.into()); | ||
| 343 | } | ||
| 344 | Ok(self) | ||
| 345 | } | ||
| 346 | |||
| 347 | fn lock(&self, offset: u64, flags: u32) -> io::Result<()> { | ||
| 348 | let _: LockResponse = self.client.request( | ||
| 349 | Command::Lock, | ||
| 350 | LockRequest { | ||
| 351 | file_id: self.id.unwrap(), | ||
| 352 | lock_sequence: 0, | ||
| 353 | locks: vec![LockElement { | ||
| 354 | offset, | ||
| 355 | length: 1, | ||
| 356 | flags, | ||
| 357 | }], | ||
| 358 | }, | ||
| 359 | )?; | ||
| 360 | Ok(()) | ||
| 361 | } | ||
| 362 | |||
| 363 | fn identity(&self) -> io::Result<(u64, u32)> { | ||
| 364 | let index: QueryInfoResponse = self.client.request( | ||
| 365 | Command::QueryInfo, | ||
| 366 | QueryInfoRequest { | ||
| 367 | info_type: InfoType::File, | ||
| 368 | file_info_class: 6, | ||
| 369 | output_buffer_length: 8, | ||
| 370 | additional_information: 0, | ||
| 371 | flags: 0, | ||
| 372 | file_id: self.id.unwrap(), | ||
| 373 | input_buffer: Vec::new(), | ||
| 374 | }, | ||
| 375 | )?; | ||
| 376 | let index = u64::from_le_bytes( | ||
| 377 | index | ||
| 378 | .output_buffer | ||
| 379 | .try_into() | ||
| 380 | .map_err(|_| io::ErrorKind::InvalidData)?, | ||
| 381 | ); | ||
| 382 | if index == 0 { | ||
| 383 | return Err(io::ErrorKind::Unsupported.into()); | ||
| 384 | } | ||
| 385 | let volume: QueryInfoResponse = self.client.request( | ||
| 386 | Command::QueryInfo, | ||
| 387 | QueryInfoRequest { | ||
| 388 | info_type: InfoType::Filesystem, | ||
| 389 | file_info_class: 1, | ||
| 390 | output_buffer_length: 1024, | ||
| 391 | additional_information: 0, | ||
| 392 | flags: 0, | ||
| 393 | file_id: self.id.unwrap(), | ||
| 394 | input_buffer: Vec::new(), | ||
| 395 | }, | ||
| 396 | )?; | ||
| 397 | let serial = volume | ||
| 398 | .output_buffer | ||
| 399 | .get(8..12) | ||
| 400 | .ok_or(io::ErrorKind::InvalidData)?; | ||
| 401 | Ok((index, u32::from_le_bytes(serial.try_into().unwrap()))) | ||
| 402 | } | ||
| 403 | |||
| 404 | fn close(mut self) -> io::Result<()> { | ||
| 405 | self.release() | ||
| 406 | } | ||
| 407 | |||
| 408 | fn release(&mut self) -> io::Result<()> { | ||
| 409 | if let Some(file_id) = self.id.take() { | ||
| 410 | let result: io::Result<CloseResponse> = self | ||
| 411 | .client | ||
| 412 | .request(Command::Close, CloseRequest { file_id, flags: 0 }); | ||
| 413 | if result.is_err() { | ||
| 414 | self.client.retire(); | ||
| 415 | } | ||
| 416 | result?; | ||
| 417 | } | ||
| 418 | Ok(()) | ||
| 419 | } | ||
| 420 | } | ||
| 421 | impl Drop for File<'_> { | ||
| 422 | fn drop(&mut self) { | ||
| 423 | let _ = self.release(); | ||
| 424 | } | ||
| 425 | } | ||
| 426 | fn read_size(params: &NegotiatedParams, credits: u16, requested: usize) -> usize { | ||
| 427 | let budget = if params.dialect != Dialect::Smb2_0_2 | ||
| 428 | && params.capabilities.contains(Capabilities::LARGE_MTU) | ||
| 429 | { | ||
| 430 | usize::from(credits.max(1)) * 65536 | ||
| 431 | } else { | ||
| 432 | 65536 | ||
| 433 | }; | ||
| 434 | requested | ||
| 435 | .min(params.max_read_size as usize) | ||
| 436 | .min(budget) | ||
| 437 | .min(1024 * 1024) | ||
| 438 | } | ||
| 439 | |||
| 440 | impl CommitIo for File<'_> { | ||
| 441 | fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> { | ||
| 442 | if output.is_empty() { | ||
| 443 | return Ok(0); | ||
| 444 | } | ||
| 445 | let mut size = 0; | ||
| 446 | let response: io::Result<ReadResponse> = | ||
| 447 | self.client.request_with(Command::Read, |connection| { | ||
| 448 | size = read_size( | ||
| 449 | &connection | ||
| 450 | .params() | ||
| 451 | .expect("connected SMB session is negotiated"), | ||
| 452 | connection.credits(), | ||
| 453 | output.len(), | ||
| 454 | ); | ||
| 455 | ( | ||
| 456 | ReadRequest { | ||
| 457 | file_id: self.id.unwrap(), | ||
| 458 | offset, | ||
| 459 | length: size as u32, | ||
| 460 | minimum_count: 1, | ||
| 461 | flags: 0, | ||
| 462 | padding: 0, | ||
| 463 | channel: 0, | ||
| 464 | remaining_bytes: 0, | ||
| 465 | read_channel_info: Vec::new(), | ||
| 466 | }, | ||
| 467 | CreditCharge(size.div_ceil(65536) as u16), | ||
| 468 | ) | ||
| 469 | }); | ||
| 470 | let response = match response { | ||
| 471 | Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(0), | ||
| 472 | result => result?, | ||
| 473 | }; | ||
| 474 | if response.data.len() > size { | ||
| 475 | self.client.retire(); | ||
| 476 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 477 | } | ||
| 478 | output[..response.data.len()].copy_from_slice(&response.data); | ||
| 479 | Ok(response.data.len()) | ||
| 480 | } | ||
| 481 | fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> { | ||
| 482 | if bytes.is_empty() { | ||
| 483 | return Ok(0); | ||
| 484 | } | ||
| 485 | let size = bytes.len().min(65536); | ||
| 486 | let response: WriteResponse = self.client.request( | ||
| 487 | Command::Write, | ||
| 488 | WriteRequest { | ||
| 489 | file_id: self.id.unwrap(), | ||
| 490 | offset, | ||
| 491 | data: bytes[..size].to_vec(), | ||
| 492 | data_offset: 112, | ||
| 493 | flags: 1, | ||
| 494 | channel: 0, | ||
| 495 | remaining_bytes: 0, | ||
| 496 | write_channel_info_offset: 0, | ||
| 497 | write_channel_info_length: 0, | ||
| 498 | }, | ||
| 499 | )?; | ||
| 500 | if response.count as usize > size { | ||
| 501 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 502 | } | ||
| 503 | Ok(response.count as usize) | ||
| 504 | } | ||
| 505 | fn flush(&mut self) -> io::Result<()> { | ||
| 506 | let _: FlushResponse = self.client.request( | ||
| 507 | Command::Flush, | ||
| 508 | FlushRequest { | ||
| 509 | file_id: self.id.unwrap(), | ||
| 510 | }, | ||
| 511 | )?; | ||
| 512 | Ok(()) | ||
| 513 | } | ||
| 514 | } | ||
| 515 | |||
| 516 | #[cfg(test)] | ||
| 517 | mod tests; | ||
crates/onestore-smb/src/tests.rs deleted-507| ... | @@ -1,507 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{ | ||
| 3 | RevisionIndex, Store, | ||
| 4 | document::{Document, Kind}, | ||
| 5 | }; | ||
| 6 | use std::{ | ||
| 7 | fs, | ||
| 8 | time::{Instant, SystemTime, UNIX_EPOCH}, | ||
| 9 | }; | ||
| 10 | |||
| 11 | mod faults; | ||
| 12 | |||
| 13 | #[test] | ||
| 14 | #[ignore = "requires an owned Samba directory and ONESTORE_SMB_DIRECTORY_ORACLE from its local filesystem"] | ||
| 15 | fn live_directory() { | ||
| 16 | let client = client(); | ||
| 17 | let bytes = fs::read(std::env::var("ONESTORE_SMB_DIRECTORY_ORACLE").unwrap()).unwrap(); | ||
| 18 | let oracle: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); | ||
| 19 | let root = oracle["path"].as_str().unwrap(); | ||
| 20 | let expected = oracle["entries"].as_array().unwrap(); | ||
| 21 | let entries = client.read_dir(root, expected.len()).unwrap(); | ||
| 22 | assert_eq!(entries.len(), expected.len()); | ||
| 23 | for expected in expected { | ||
| 24 | let entry = entries | ||
| 25 | .iter() | ||
| 26 | .find(|entry| entry.name == expected["name"]) | ||
| 27 | .unwrap(); | ||
| 28 | let directory = expected["directory"].as_bool().unwrap(); | ||
| 29 | assert_eq!(entry.attributes & 0x10 != 0, directory, "{}", entry.name); | ||
| 30 | if !directory { | ||
| 31 | assert_eq!( | ||
| 32 | entry.size, | ||
| 33 | expected["size"].as_u64().unwrap(), | ||
| 34 | "{}", | ||
| 35 | entry.name | ||
| 36 | ); | ||
| 37 | } | ||
| 38 | } | ||
| 39 | assert_eq!( | ||
| 40 | client.read_dir(root, entries.len() - 1).unwrap_err().kind(), | ||
| 41 | io::ErrorKind::FileTooLarge | ||
| 42 | ); | ||
| 43 | assert_eq!(client.read_dir(root, entries.len()).unwrap(), entries); | ||
| 44 | assert!( | ||
| 45 | client | ||
| 46 | .read_dir(&format!("{root}/empty"), 0) | ||
| 47 | .unwrap() | ||
| 48 | .is_empty() | ||
| 49 | ); | ||
| 50 | assert_eq!( | ||
| 51 | client | ||
| 52 | .read_dir(&format!("{root}/missing"), 1) | ||
| 53 | .unwrap_err() | ||
| 54 | .kind(), | ||
| 55 | io::ErrorKind::NotFound | ||
| 56 | ); | ||
| 57 | assert_eq!( | ||
| 58 | client | ||
| 59 | .read_dir(&format!("{root}/file.one"), 1) | ||
| 60 | .unwrap_err() | ||
| 61 | .kind(), | ||
| 62 | io::ErrorKind::NotADirectory | ||
| 63 | ); | ||
| 64 | assert_eq!( | ||
| 65 | client | ||
| 66 | .read_dir(&format!("{root}/denied"), 1) | ||
| 67 | .unwrap_err() | ||
| 68 | .kind(), | ||
| 69 | io::ErrorKind::PermissionDenied | ||
| 70 | ); | ||
| 71 | assert!( | ||
| 72 | client | ||
| 73 | .read_dir("", 10_000) | ||
| 74 | .unwrap() | ||
| 75 | .iter() | ||
| 76 | .any(|entry| entry.name == root) | ||
| 77 | ); | ||
| 78 | } | ||
| 79 | |||
| 80 | #[test] | ||
| 81 | #[ignore = "requires an owned Samba directory through a proxy that interrupts a later directory page or close"] | ||
| 82 | fn live_directory_interruption() { | ||
| 83 | let client = client(); | ||
| 84 | let root = std::env::var("ONESTORE_SMB_DIRECTORY").unwrap(); | ||
| 85 | let started = Instant::now(); | ||
| 86 | assert!(client.read_dir(&root, 10_000).is_err()); | ||
| 87 | assert!(started.elapsed() < Duration::from_secs(10)); | ||
| 88 | assert_eq!( | ||
| 89 | client.read_dir(&root, 10_000).unwrap_err().kind(), | ||
| 90 | io::ErrorKind::NotConnected | ||
| 91 | ); | ||
| 92 | } | ||
| 93 | |||
| 94 | fn client() -> Client { | ||
| 95 | Client::connect( | ||
| 96 | &std::env::var("ONESTORE_SMB_LAB").unwrap(), | ||
| 97 | "agent", | ||
| 98 | Credentials::default(), | ||
| 99 | Duration::from_secs(5), | ||
| 100 | ) | ||
| 101 | .unwrap() | ||
| 102 | } | ||
| 103 | fn create(client: &Client, path: &str, bytes: &[u8]) { | ||
| 104 | let response: CreateResponse = client | ||
| 105 | .request( | ||
| 106 | Command::Create, | ||
| 107 | CreateRequest { | ||
| 108 | requested_oplock_level: OplockLevel::None, | ||
| 109 | impersonation_level: ImpersonationLevel::Impersonation, | ||
| 110 | desired_access: FileAccessMask::new(0xc0000000), | ||
| 111 | file_attributes: 0, | ||
| 112 | share_access: ShareAccess(7), | ||
| 113 | create_disposition: CreateDisposition::FileCreate, | ||
| 114 | create_options: 0x42, | ||
| 115 | name: path.to_owned(), | ||
| 116 | create_contexts: Vec::new(), | ||
| 117 | }, | ||
| 118 | ) | ||
| 119 | .unwrap(); | ||
| 120 | let mut file = File { | ||
| 121 | client, | ||
| 122 | id: Some(response.file_id), | ||
| 123 | }; | ||
| 124 | let mut offset = 0; | ||
| 125 | while offset < bytes.len() { | ||
| 126 | offset += file.write_at(offset as u64, &bytes[offset..]).unwrap(); | ||
| 127 | } | ||
| 128 | file.flush().unwrap(); | ||
| 129 | file.close().unwrap(); | ||
| 130 | } | ||
| 131 | fn text(bytes: &[u8]) -> (ExGuid, ExGuid, String) { | ||
| 132 | let store = Store::parse(bytes).unwrap(); | ||
| 133 | assert!(store.checksum_mismatches.is_empty()); | ||
| 134 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 135 | index.validate_current().unwrap(); | ||
| 136 | let doc = Document::parse(&index).unwrap(); | ||
| 137 | doc.spaces | ||
| 138 | .iter() | ||
| 139 | .find_map(|(sid, space)| { | ||
| 140 | let revision = space.active().unwrap(); | ||
| 141 | revision | ||
| 142 | .nodes | ||
| 143 | .iter() | ||
| 144 | .find_map(|(oid, node)| match &node.kind { | ||
| 145 | Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())), | ||
| 146 | _ => None, | ||
| 147 | }) | ||
| 148 | }) | ||
| 149 | .unwrap() | ||
| 150 | } | ||
| 151 | #[test] | ||
| 152 | #[ignore = "requires disposable Samba and an existing ONESTORE_SMB_EVIDENCE directory"] | ||
| 153 | fn live_coordination() { | ||
| 154 | let output = std::env::var("ONESTORE_SMB_EVIDENCE").unwrap(); | ||
| 155 | let writer = client(); | ||
| 156 | let path = format!( | ||
| 157 | "adapter-{}.one", | ||
| 158 | SystemTime::now() | ||
| 159 | .duration_since(UNIX_EPOCH) | ||
| 160 | .unwrap() | ||
| 161 | .as_nanos() | ||
| 162 | ); | ||
| 163 | let source = onestore::create_section(&path, "Before café 🦀", "Fixture").unwrap(); | ||
| 164 | create(&writer, &path, &source); | ||
| 165 | assert_eq!(writer.read(&path, 1 << 20).unwrap(), source); | ||
| 166 | let readers: Vec<_> = (0..12).map(|_| client()).collect(); | ||
| 167 | let mut held: Vec<_> = readers | ||
| 168 | .iter() | ||
| 169 | .map(|client| { | ||
| 170 | client | ||
| 171 | .open(&path, false) | ||
| 172 | .unwrap() | ||
| 173 | .coordinate(&path, false) | ||
| 174 | .unwrap() | ||
| 175 | }) | ||
| 176 | .collect(); | ||
| 177 | let (sid, oid, before) = text(&source); | ||
| 178 | let replacement = "After café 🦀"; | ||
| 179 | writer | ||
| 180 | .commit_text( | ||
| 181 | &path, | ||
| 182 | &source, | ||
| 183 | sid, | ||
| 184 | oid, | ||
| 185 | 0..before.encode_utf16().count() as u32, | ||
| 186 | replacement, | ||
| 187 | ) | ||
| 188 | .unwrap(); | ||
| 189 | let after = writer.read(&path, 1 << 20).unwrap(); | ||
| 190 | assert_eq!(text(&after).2, replacement); | ||
| 191 | for file in &mut held { | ||
| 192 | let snapshot = onestore::read_snapshot(|offset, out| file.read_at(offset, out), 1 << 20) | ||
| 193 | .unwrap() | ||
| 194 | .unwrap(); | ||
| 195 | assert_eq!(snapshot, after); | ||
| 196 | } | ||
| 197 | let error = writer | ||
| 198 | .commit_text(&path, &source, sid, oid, 0..1, "X") | ||
| 199 | .unwrap_err(); | ||
| 200 | assert_eq!(error.state, CommitState::NotCommitted); | ||
| 201 | assert_eq!(error.error.kind(), io::ErrorKind::ResourceBusy); | ||
| 202 | assert_eq!(writer.read(&path, 1 << 20).unwrap(), after); | ||
| 203 | drop(held); | ||
| 204 | |||
| 205 | let stale = writer.open(&path, true).unwrap(); | ||
| 206 | let maintenance = client(); | ||
| 207 | let guard = maintenance.open(&path, false).unwrap(); | ||
| 208 | let _: LockResponse = maintenance | ||
| 209 | .request( | ||
| 210 | Command::Lock, | ||
| 211 | LockRequest { | ||
| 212 | file_id: guard.id.unwrap(), | ||
| 213 | lock_sequence: 0, | ||
| 214 | locks: vec![ | ||
| 215 | LockElement { | ||
| 216 | offset: 0xfffffffc, | ||
| 217 | length: 1, | ||
| 218 | flags: 0x12, | ||
| 219 | }, | ||
| 220 | LockElement { | ||
| 221 | offset: 0xffffeffc, | ||
| 222 | length: 4096, | ||
| 223 | flags: 0x12, | ||
| 224 | }, | ||
| 225 | ], | ||
| 226 | }, | ||
| 227 | ) | ||
| 228 | .unwrap(); | ||
| 229 | let replacement_path = format!("{path}.replacement"); | ||
| 230 | create(&maintenance, &replacement_path, &source); | ||
| 231 | let mut connection = maintenance | ||
| 232 | .connection | ||
| 233 | .lock() | ||
| 234 | .unwrap() | ||
| 235 | .as_ref() | ||
| 236 | .unwrap() | ||
| 237 | .clone(); | ||
| 238 | maintenance | ||
| 239 | .runtime | ||
| 240 | .lock() | ||
| 241 | .unwrap() | ||
| 242 | .as_ref() | ||
| 243 | .unwrap() | ||
| 244 | .block_on( | ||
| 245 | maintenance | ||
| 246 | .tree | ||
| 247 | .rename(&mut connection, &path, &format!("{path}.old")), | ||
| 248 | ) | ||
| 249 | .unwrap(); | ||
| 250 | maintenance | ||
| 251 | .runtime | ||
| 252 | .lock() | ||
| 253 | .unwrap() | ||
| 254 | .as_ref() | ||
| 255 | .unwrap() | ||
| 256 | .block_on( | ||
| 257 | maintenance | ||
| 258 | .tree | ||
| 259 | .rename(&mut connection, &replacement_path, &path), | ||
| 260 | ) | ||
| 261 | .unwrap(); | ||
| 262 | drop(connection); | ||
| 263 | guard.close().unwrap(); | ||
| 264 | let error = stale.coordinate(&path, true).err().unwrap(); | ||
| 265 | assert_eq!(error.kind(), io::ErrorKind::ResourceBusy); | ||
| 266 | assert_eq!(writer.read(&path, 1 << 20).unwrap(), source); | ||
| 267 | |||
| 268 | let retiring = client(); | ||
| 269 | let file = retiring | ||
| 270 | .open(&path, true) | ||
| 271 | .unwrap() | ||
| 272 | .coordinate(&path, true) | ||
| 273 | .unwrap(); | ||
| 274 | retiring.retire(); | ||
| 275 | assert_eq!( | ||
| 276 | retiring.read(&path, 1 << 20).unwrap_err().kind(), | ||
| 277 | io::ErrorKind::NotConnected | ||
| 278 | ); | ||
| 279 | drop(file); | ||
| 280 | let deadline = Instant::now() + Duration::from_secs(5); | ||
| 281 | loop { | ||
| 282 | match writer | ||
| 283 | .open(&path, true) | ||
| 284 | .and_then(|file| file.coordinate(&path, true)) | ||
| 285 | { | ||
| 286 | Ok(file) => { | ||
| 287 | file.close().unwrap(); | ||
| 288 | break; | ||
| 289 | } | ||
| 290 | Err(error) | ||
| 291 | if error.kind() == io::ErrorKind::WouldBlock && Instant::now() < deadline => | ||
| 292 | { | ||
| 293 | std::thread::sleep(Duration::from_millis(10)) | ||
| 294 | } | ||
| 295 | Err(error) => panic!("retired connection retained locks: {error}"), | ||
| 296 | } | ||
| 297 | } | ||
| 298 | |||
| 299 | let mut unfinished = writer | ||
| 300 | .open(&path, true) | ||
| 301 | .unwrap() | ||
| 302 | .coordinate(&path, true) | ||
| 303 | .unwrap(); | ||
| 304 | assert_eq!( | ||
| 305 | unfinished | ||
| 306 | .write_at(source.len() as u64, b"unpublished") | ||
| 307 | .unwrap(), | ||
| 308 | 11 | ||
| 309 | ); | ||
| 310 | unfinished.flush().unwrap(); | ||
| 311 | unfinished.close().unwrap(); | ||
| 312 | let snapshot = writer.read(&path, 1 << 20).unwrap(); | ||
| 313 | assert_eq!(snapshot.len(), source.len() + 11); | ||
| 314 | assert_eq!(text(&snapshot).2, before); | ||
| 315 | writer | ||
| 316 | .commit_text( | ||
| 317 | &path, | ||
| 318 | &snapshot, | ||
| 319 | sid, | ||
| 320 | oid, | ||
| 321 | 0..before.encode_utf16().count() as u32, | ||
| 322 | "Recovered café 🦀", | ||
| 323 | ) | ||
| 324 | .unwrap(); | ||
| 325 | let recovered = writer.read(&path, 1 << 20).unwrap(); | ||
| 326 | assert_eq!(text(&recovered).2, "Recovered café 🦀"); | ||
| 327 | let spare = client(); | ||
| 328 | tokio::runtime::Builder::new_current_thread() | ||
| 329 | .build() | ||
| 330 | .unwrap() | ||
| 331 | .block_on(async { | ||
| 332 | drop(spare); | ||
| 333 | }); | ||
| 334 | fs::write(std::path::Path::new(&output).join("source.one"), &source).unwrap(); | ||
| 335 | fs::write(std::path::Path::new(&output).join("committed.one"), &after).unwrap(); | ||
| 336 | fs::write( | ||
| 337 | std::path::Path::new(&output).join("recovered.one"), | ||
| 338 | &recovered, | ||
| 339 | ) | ||
| 340 | .unwrap(); | ||
| 341 | println!( | ||
| 342 | "{}", | ||
| 343 | serde_json::json!({"path":path,"readers":12,"committed_while_readers_held":true,"fresh_reads":12,"stale_snapshot_rejected":true,"replaced_handle_rejected":true,"retirement_releases_locks":true,"unpublished_tail_recovered":true,"drop_inside_runtime":true}) | ||
| 344 | ); | ||
| 345 | } | ||
| 346 | |||
| 347 | #[test] | ||
| 348 | #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] | ||
| 349 | fn live_storage_inspection() { | ||
| 350 | let reader = client(); | ||
| 351 | for (index, fixture) in [ | ||
| 352 | "native-encrypted/encrypted-01/notebook/synthetic.one", | ||
| 353 | "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one", | ||
| 354 | ] | ||
| 355 | .into_iter() | ||
| 356 | .enumerate() | ||
| 357 | { | ||
| 358 | let source = fs::read(format!("../../corpus/{fixture}")).unwrap(); | ||
| 359 | let path = format!( | ||
| 360 | "inspection-{index}-{}.one", | ||
| 361 | SystemTime::now() | ||
| 362 | .duration_since(UNIX_EPOCH) | ||
| 363 | .unwrap() | ||
| 364 | .as_nanos() | ||
| 365 | ); | ||
| 366 | create(&reader, &path, &source); | ||
| 367 | assert_eq!(reader.read_storage(&path, source.len()).unwrap(), source); | ||
| 368 | assert_eq!( | ||
| 369 | reader.read(&path, source.len()).unwrap_err().kind(), | ||
| 370 | io::ErrorKind::WouldBlock | ||
| 371 | ); | ||
| 372 | let maintenance = client(); | ||
| 373 | let guard = maintenance.open(&path, false).unwrap(); | ||
| 374 | guard.lock(0xfffffffb, 0x12).unwrap(); | ||
| 375 | assert_eq!( | ||
| 376 | reader.read_storage(&path, source.len()).unwrap_err().kind(), | ||
| 377 | io::ErrorKind::WouldBlock | ||
| 378 | ); | ||
| 379 | guard.close().unwrap(); | ||
| 380 | assert_eq!(reader.read_storage(&path, source.len()).unwrap(), source); | ||
| 381 | } | ||
| 382 | } | ||
| 383 | |||
| 384 | #[test] | ||
| 385 | #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] | ||
| 386 | fn live_assets() { | ||
| 387 | let reader = client(); | ||
| 388 | let writer = client(); | ||
| 389 | for size in [0, 1, 65535, 65536, 65537, 1048577] { | ||
| 390 | let bytes: Vec<_> = (0..size).map(|index| (index % 251) as u8).collect(); | ||
| 391 | let path = format!( | ||
| 392 | "asset-{size}-{}.onebin", | ||
| 393 | SystemTime::now() | ||
| 394 | .duration_since(UNIX_EPOCH) | ||
| 395 | .unwrap() | ||
| 396 | .as_nanos() | ||
| 397 | ); | ||
| 398 | create(&writer, &path, &bytes); | ||
| 399 | assert_eq!(reader.read_asset(&path, size).unwrap(), bytes); | ||
| 400 | if size != 0 { | ||
| 401 | assert_eq!( | ||
| 402 | reader.read_asset(&path, size - 1).unwrap_err().kind(), | ||
| 403 | io::ErrorKind::FileTooLarge | ||
| 404 | ); | ||
| 405 | assert_eq!(reader.read_asset(&path, size).unwrap(), bytes); | ||
| 406 | } | ||
| 407 | let writing = writer.open(&path, true).unwrap(); | ||
| 408 | assert_eq!( | ||
| 409 | reader.read_asset(&path, size).unwrap_err().kind(), | ||
| 410 | io::ErrorKind::WouldBlock | ||
| 411 | ); | ||
| 412 | writing.close().unwrap(); | ||
| 413 | let asset = reader.open_shared(&path, false, 1).unwrap(); | ||
| 414 | assert_eq!( | ||
| 415 | writer.open(&path, true).err().unwrap().kind(), | ||
| 416 | io::ErrorKind::WouldBlock | ||
| 417 | ); | ||
| 418 | let other = writer.open_shared(&path, false, 1).unwrap(); | ||
| 419 | other.close().unwrap(); | ||
| 420 | asset.close().unwrap(); | ||
| 421 | writer.open(&path, true).unwrap().close().unwrap(); | ||
| 422 | assert_eq!(reader.read_asset(&path, size).unwrap(), bytes); | ||
| 423 | } | ||
| 424 | assert_eq!( | ||
| 425 | reader | ||
| 426 | .read_asset("absent-payload.onebin", 0) | ||
| 427 | .unwrap_err() | ||
| 428 | .kind(), | ||
| 429 | io::ErrorKind::NotFound | ||
| 430 | ); | ||
| 431 | } | ||
| 432 | |||
| 433 | #[test] | ||
| 434 | #[ignore = "requires an owned Samba fixture and maintenance controller"] | ||
| 435 | fn live_reader_hold() { | ||
| 436 | let client = client(); | ||
| 437 | let path = std::env::var("ONESTORE_SMB_PATH").unwrap(); | ||
| 438 | let output = std::path::PathBuf::from(std::env::var("ONESTORE_SMB_HOLD").unwrap()); | ||
| 439 | assert!(!output.join("ready").exists() && !output.join("release").exists()); | ||
| 440 | let mut file = client | ||
| 441 | .open(&path, false) | ||
| 442 | .unwrap() | ||
| 443 | .coordinate(&path, false) | ||
| 444 | .unwrap(); | ||
| 445 | fs::write(output.join("ready"), b"held").unwrap(); | ||
| 446 | let deadline = Instant::now() + Duration::from_secs(300); | ||
| 447 | let mut accepted = 0; | ||
| 448 | let mut retries = 0; | ||
| 449 | while !output.join("release").exists() { | ||
| 450 | assert!( | ||
| 451 | Instant::now() < deadline, | ||
| 452 | "maintenance controller timed out" | ||
| 453 | ); | ||
| 454 | match onestore::read_snapshot(|offset, out| file.read_at(offset, out), 256 << 20).unwrap() { | ||
| 455 | Some(_) => accepted += 1, | ||
| 456 | None => retries += 1, | ||
| 457 | } | ||
| 458 | std::thread::sleep(Duration::from_millis(5)); | ||
| 459 | } | ||
| 460 | file.close().unwrap(); | ||
| 461 | assert!(accepted > 0); | ||
| 462 | fs::write( | ||
| 463 | output.join("released.json"), | ||
| 464 | serde_json::to_vec(&serde_json::json!({"accepted": accepted, "retries": retries})).unwrap(), | ||
| 465 | ) | ||
| 466 | .unwrap(); | ||
| 467 | } | ||
| 468 | |||
| 469 | #[test] | ||
| 470 | fn read_limits_respect_negotiation_and_available_credits() { | ||
| 471 | for dialect in Dialect::ALL { | ||
| 472 | for large_mtu in [false, true] { | ||
| 473 | for max_read_size in [65536, 65537, 131072, 1048576, u32::MAX] { | ||
| 474 | let params = NegotiatedParams { | ||
| 475 | dialect: *dialect, | ||
| 476 | max_read_size, | ||
| 477 | max_write_size: 65536, | ||
| 478 | max_transact_size: 65536, | ||
| 479 | server_guid: Default::default(), | ||
| 480 | signing_required: false, | ||
| 481 | capabilities: Capabilities(if large_mtu { | ||
| 482 | Capabilities::LARGE_MTU | ||
| 483 | } else { | ||
| 484 | 0 | ||
| 485 | }), | ||
| 486 | gmac_negotiated: false, | ||
| 487 | cipher: None, | ||
| 488 | compression_supported: false, | ||
| 489 | }; | ||
| 490 | for credits in [0, 1, 2, 3, 15, 16, 17, u16::MAX] { | ||
| 491 | for requested in [1, 1024, 65535, 65536, 65537, 131072, 1048576, usize::MAX] { | ||
| 492 | let size = read_size(&params, credits, requested); | ||
| 493 | assert!(size > 0 && size <= requested && size <= max_read_size as usize); | ||
| 494 | assert!(size <= 1048576); | ||
| 495 | assert!(size.div_ceil(65536) <= usize::from(credits.max(1))); | ||
| 496 | if *dialect == Dialect::Smb2_0_2 || !large_mtu { | ||
| 497 | assert!(size <= 65536); | ||
| 498 | } else if credits >= 16 && max_read_size >= 1048576 && requested >= 1048576 | ||
| 499 | { | ||
| 500 | assert_eq!(size, 1048576); | ||
| 501 | } | ||
| 502 | } | ||
| 503 | } | ||
| 504 | } | ||
| 505 | } | ||
| 506 | } | ||
| 507 | } | ||
crates/onestore-smb/src/tests/faults.rs deleted-511| ... | @@ -1,511 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use serde_json::{Value, json}; | ||
| 3 | use std::{collections::BTreeMap, path::Path, process::Child}; | ||
| 4 | |||
| 5 | #[derive(Clone)] | ||
| 6 | struct Snapshot { | ||
| 7 | content: BTreeMap<ExGuid, String>, | ||
| 8 | modified: BTreeMap<(ExGuid, ExGuid), u32>, | ||
| 9 | } | ||
| 10 | |||
| 11 | fn snapshot(bytes: &[u8]) -> Snapshot { | ||
| 12 | let store = Store::parse(bytes).unwrap(); | ||
| 13 | assert!(store.checksum_mismatches.is_empty()); | ||
| 14 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 15 | index.validate_current().unwrap(); | ||
| 16 | Document::parse(&index).unwrap(); | ||
| 17 | let mut content = BTreeMap::new(); | ||
| 18 | let mut modified = BTreeMap::new(); | ||
| 19 | for (sid, space) in &index.spaces { | ||
| 20 | let revision = index | ||
| 21 | .resolve(*sid, space.labels[&(ExGuid::default(), 1)]) | ||
| 22 | .unwrap(); | ||
| 23 | let mut objects = BTreeMap::new(); | ||
| 24 | for (oid, object) in &revision.objects { | ||
| 25 | if let Some(onestore::FileDataReference::Internal(guid)) = | ||
| 26 | object.file_reference().unwrap() | ||
| 27 | { | ||
| 28 | store.file_data(guid).unwrap(); | ||
| 29 | } | ||
| 30 | let data = match object.data { | ||
| 31 | onestore::ObjectData::Properties(bytes) => { | ||
| 32 | let mut properties = onestore::PropertySets::parse(bytes).unwrap(); | ||
| 33 | for property in &mut properties.sets[0] { | ||
| 34 | if property.id == 0x14001d7a { | ||
| 35 | let onestore::Value::Bytes(value) = property.value else { | ||
| 36 | panic!() | ||
| 37 | }; | ||
| 38 | assert!( | ||
| 39 | modified | ||
| 40 | .insert( | ||
| 41 | (*sid, *oid), | ||
| 42 | u32::from_le_bytes(value.try_into().unwrap()) | ||
| 43 | ) | ||
| 44 | .is_none() | ||
| 45 | ); | ||
| 46 | property.value = onestore::Value::Bytes(&[0; 4]); | ||
| 47 | } | ||
| 48 | } | ||
| 49 | format!("{properties:?}") | ||
| 50 | } | ||
| 51 | other => format!("{other:?}"), | ||
| 52 | }; | ||
| 53 | objects.insert( | ||
| 54 | *oid, | ||
| 55 | ( | ||
| 56 | object.jcid, | ||
| 57 | object.reference_count, | ||
| 58 | data, | ||
| 59 | format!("{:?}", object.references().unwrap()), | ||
| 60 | ), | ||
| 61 | ); | ||
| 62 | } | ||
| 63 | content.insert(*sid, format!("{:?} {objects:?}", revision.roots)); | ||
| 64 | } | ||
| 65 | Snapshot { content, modified } | ||
| 66 | } | ||
| 67 | |||
| 68 | fn stamp() -> u32 { | ||
| 69 | (SystemTime::now() | ||
| 70 | .duration_since(UNIX_EPOCH) | ||
| 71 | .unwrap() | ||
| 72 | .as_secs() | ||
| 73 | - 315532800) | ||
| 74 | .try_into() | ||
| 75 | .unwrap() | ||
| 76 | } | ||
| 77 | |||
| 78 | fn published( | ||
| 79 | actual: &Snapshot, | ||
| 80 | old: &Snapshot, | ||
| 81 | new: &Snapshot, | ||
| 82 | time: std::ops::RangeInclusive<u32>, | ||
| 83 | ) -> bool { | ||
| 84 | if actual.content == old.content { | ||
| 85 | assert_eq!( | ||
| 86 | actual.modified, old.modified, | ||
| 87 | "Unpublished modification time changed" | ||
| 88 | ); | ||
| 89 | return false; | ||
| 90 | } | ||
| 91 | assert_eq!( | ||
| 92 | actual.content, new.content, | ||
| 93 | "Partial or unexpected publication" | ||
| 94 | ); | ||
| 95 | assert!(actual.modified.keys().eq(new.modified.keys())); | ||
| 96 | for (key, value) in &actual.modified { | ||
| 97 | if old.modified.get(key) != new.modified.get(key) { | ||
| 98 | assert!( | ||
| 99 | time.contains(value), | ||
| 100 | "Modification time outside the commit interval" | ||
| 101 | ); | ||
| 102 | } else { | ||
| 103 | assert_eq!( | ||
| 104 | *value, new.modified[key], | ||
| 105 | "Unrelated modification time changed" | ||
| 106 | ); | ||
| 107 | } | ||
| 108 | } | ||
| 109 | true | ||
| 110 | } | ||
| 111 | |||
| 112 | #[test] | ||
| 113 | fn publication_oracle_bounds_changed_timestamps_and_preserves_others() { | ||
| 114 | let id = ExGuid::default(); | ||
| 115 | let other = ExGuid { n: 1, ..id }; | ||
| 116 | let old = Snapshot { | ||
| 117 | content: BTreeMap::from([(id, "old".into())]), | ||
| 118 | modified: BTreeMap::from([((id, id), 10), ((id, other), 7)]), | ||
| 119 | }; | ||
| 120 | let new = Snapshot { | ||
| 121 | content: BTreeMap::from([(id, "new".into())]), | ||
| 122 | modified: BTreeMap::from([((id, id), 20), ((id, other), 7)]), | ||
| 123 | }; | ||
| 124 | let mut actual = new.clone(); | ||
| 125 | actual.modified.insert((id, id), 30); | ||
| 126 | assert!(published(&actual, &old, &new, 25..=35)); | ||
| 127 | assert!(!published(&old, &old, &new, 25..=35)); | ||
| 128 | for (key, value) in [((id, id), 24), ((id, id), 36), ((id, other), 30)] { | ||
| 129 | let mut changed = actual.clone(); | ||
| 130 | changed.modified.insert(key, value); | ||
| 131 | assert!(std::panic::catch_unwind(|| published(&changed, &old, &new, 25..=35)).is_err()); | ||
| 132 | } | ||
| 133 | actual.content = old.content.clone(); | ||
| 134 | assert!(std::panic::catch_unwind(|| published(&actual, &old, &new, 25..=35)).is_err()); | ||
| 135 | } | ||
| 136 | |||
| 137 | struct Proxy(Child); | ||
| 138 | impl Drop for Proxy { | ||
| 139 | fn drop(&mut self) { | ||
| 140 | let _ = self.0.kill(); | ||
| 141 | let _ = self.0.wait(); | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | fn records(output: &Path) -> Vec<Value> { | ||
| 146 | let data = fs::read_to_string(output.join("proxy.jsonl")).unwrap(); | ||
| 147 | data.rsplit_once('\n') | ||
| 148 | .map_or("", |(complete, _)| complete) | ||
| 149 | .lines() | ||
| 150 | .map(|line| serde_json::from_str(line).unwrap()) | ||
| 151 | .collect() | ||
| 152 | } | ||
| 153 | |||
| 154 | fn configure(output: &Path, state: Value) -> usize { | ||
| 155 | fs::write(output.join("control.tmp"), state.to_string()).unwrap(); | ||
| 156 | fs::rename(output.join("control.tmp"), output.join("control.json")).unwrap(); | ||
| 157 | let deadline = Instant::now() + Duration::from_secs(5); | ||
| 158 | loop { | ||
| 159 | let events = records(output); | ||
| 160 | if let Some(index) = events | ||
| 161 | .iter() | ||
| 162 | .rposition(|event| event.get("control") == Some(&state)) | ||
| 163 | { | ||
| 164 | return index + 1; | ||
| 165 | } | ||
| 166 | assert!( | ||
| 167 | Instant::now() < deadline, | ||
| 168 | "proxy did not acknowledge its control state" | ||
| 169 | ); | ||
| 170 | std::thread::sleep(Duration::from_millis(5)); | ||
| 171 | } | ||
| 172 | } | ||
| 173 | |||
| 174 | fn proxy(output: &Path) -> (Proxy, String) { | ||
| 175 | let address = std::env::var("ONESTORE_SMB_LAB").unwrap(); | ||
| 176 | let (host, port) = address.rsplit_once(':').unwrap(); | ||
| 177 | let mut proxy = Proxy( | ||
| 178 | std::process::Command::new("python3") | ||
| 179 | .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tools/smb-proxy.py")) | ||
| 180 | .arg(output.join("control.json")) | ||
| 181 | .args(["--port", "0", "--server", host, "--server-port", port]) | ||
| 182 | .stdout(fs::File::create(output.join("proxy.jsonl")).unwrap()) | ||
| 183 | .stderr(fs::File::create(output.join("proxy.stderr")).unwrap()) | ||
| 184 | .spawn() | ||
| 185 | .unwrap(), | ||
| 186 | ); | ||
| 187 | let deadline = Instant::now() + Duration::from_secs(5); | ||
| 188 | let port = loop { | ||
| 189 | if let Some(port) = records(output) | ||
| 190 | .iter() | ||
| 191 | .find_map(|event| event["listening"].as_u64()) | ||
| 192 | { | ||
| 193 | break port; | ||
| 194 | } | ||
| 195 | assert!(proxy.0.try_wait().unwrap().is_none()); | ||
| 196 | assert!(Instant::now() < deadline, "proxy did not start"); | ||
| 197 | std::thread::sleep(Duration::from_millis(5)); | ||
| 198 | }; | ||
| 199 | (proxy, format!("127.0.0.1:{port}")) | ||
| 200 | } | ||
| 201 | |||
| 202 | #[test] | ||
| 203 | #[ignore = "requires an owned Samba share and a new ONESTORE_SMB_EVIDENCE directory"] | ||
| 204 | fn live_asset_loss() { | ||
| 205 | let output = std::path::PathBuf::from(std::env::var("ONESTORE_SMB_EVIDENCE").unwrap()); | ||
| 206 | fs::create_dir(&output).unwrap(); | ||
| 207 | let (_proxy, address) = proxy(&output); | ||
| 208 | let observer = client(); | ||
| 209 | let bytes: Vec<_> = (0..1048577).map(|index| (index % 251) as u8).collect(); | ||
| 210 | let path = format!( | ||
| 211 | "asset-loss-{}.onebin", | ||
| 212 | SystemTime::now() | ||
| 213 | .duration_since(UNIX_EPOCH) | ||
| 214 | .unwrap() | ||
| 215 | .as_nanos() | ||
| 216 | ); | ||
| 217 | create(&observer, &path, &bytes); | ||
| 218 | for (command, occurrence) in [(8, 1), (8, 9), (8, 17), (8, 18), (6, 1)] { | ||
| 219 | for direction in ["request", "response"] { | ||
| 220 | let reader = Client::connect( | ||
| 221 | &address, | ||
| 222 | "agent", | ||
| 223 | Credentials::default(), | ||
| 224 | Duration::from_secs(5), | ||
| 225 | ) | ||
| 226 | .unwrap(); | ||
| 227 | // Response occurrences count only replies with the selected status. | ||
| 228 | let begin = configure( | ||
| 229 | &output, | ||
| 230 | json!({"cut":command,"occurrence":if command == 8 && occurrence == 18 && direction == "response" { 1 } else { occurrence }, | ||
| 231 | "direction":direction,"status":if command == 8 && occurrence == 18 { "0xc0000011" } else { "0x0" }}), | ||
| 232 | ); | ||
| 233 | assert!( | ||
| 234 | reader.read_asset(&path, bytes.len()).is_err(), | ||
| 235 | "{command}/{occurrence}/{direction}" | ||
| 236 | ); | ||
| 237 | assert_eq!( | ||
| 238 | reader.read_asset(&path, bytes.len()).unwrap_err().kind(), | ||
| 239 | io::ErrorKind::NotConnected | ||
| 240 | ); | ||
| 241 | assert_eq!( | ||
| 242 | records(&output)[begin..] | ||
| 243 | .iter() | ||
| 244 | .filter(|row| row.get("cut").is_some()) | ||
| 245 | .count(), | ||
| 246 | 1 | ||
| 247 | ); | ||
| 248 | configure( | ||
| 249 | &output, | ||
| 250 | json!({"phase":format!("{command}-{occurrence}-{direction}-reconnected")}), | ||
| 251 | ); | ||
| 252 | let reconnected = Client::connect( | ||
| 253 | &address, | ||
| 254 | "agent", | ||
| 255 | Credentials::default(), | ||
| 256 | Duration::from_secs(5), | ||
| 257 | ) | ||
| 258 | .unwrap(); | ||
| 259 | assert_eq!(reconnected.read_asset(&path, bytes.len()).unwrap(), bytes); | ||
| 260 | } | ||
| 261 | } | ||
| 262 | assert_eq!(observer.read_asset(&path, bytes.len()).unwrap(), bytes); | ||
| 263 | } | ||
| 264 | |||
| 265 | #[test] | ||
| 266 | #[ignore = "requires an owned Samba share and a new ONESTORE_SMB_EVIDENCE directory"] | ||
| 267 | fn live_message_loss() { | ||
| 268 | let output = std::path::PathBuf::from(std::env::var("ONESTORE_SMB_EVIDENCE").unwrap()); | ||
| 269 | fs::create_dir(&output).unwrap(); | ||
| 270 | fs::create_dir(output.join("interrupted")).unwrap(); | ||
| 271 | fs::create_dir(output.join("recovered")).unwrap(); | ||
| 272 | fs::create_dir(output.join("source")).unwrap(); | ||
| 273 | let (_proxy, proxied) = proxy(&output); | ||
| 274 | let observer = client(); | ||
| 275 | let prefix = SystemTime::now() | ||
| 276 | .duration_since(UNIX_EPOCH) | ||
| 277 | .unwrap() | ||
| 278 | .as_nanos(); | ||
| 279 | let fixtures = [ | ||
| 280 | ( | ||
| 281 | "chunked", | ||
| 282 | onestore::create_section("fault.one", "Before café 🦀", "Fault test").unwrap(), | ||
| 283 | ), | ||
| 284 | ( | ||
| 285 | "carry", | ||
| 286 | fs::read( | ||
| 287 | Path::new(env!("CARGO_MANIFEST_DIR")) | ||
| 288 | .join("../../corpus/append/round-01/tx-255/notebook/synthetic.one"), | ||
| 289 | ) | ||
| 290 | .unwrap(), | ||
| 291 | ), | ||
| 292 | ]; | ||
| 293 | let mut results = Vec::new(); | ||
| 294 | for (fixture, source) in fixtures { | ||
| 295 | fs::write( | ||
| 296 | output.join("source").join(format!("{fixture}.one")), | ||
| 297 | &source, | ||
| 298 | ) | ||
| 299 | .unwrap(); | ||
| 300 | let (sid, oid, before) = text(&source); | ||
| 301 | let after = if fixture == "chunked" { | ||
| 302 | "After café 🦀 ".repeat(6000) | ||
| 303 | } else { | ||
| 304 | "After café 🦀".to_owned() | ||
| 305 | }; | ||
| 306 | let suffix = " [reconnected]"; | ||
| 307 | fs::write( | ||
| 308 | output.join(format!("{fixture}-intent.json")), | ||
| 309 | serde_json::to_vec(&json!({"before":before,"after":after,"suffix":suffix})).unwrap(), | ||
| 310 | ) | ||
| 311 | .unwrap(); | ||
| 312 | let old = snapshot(&source); | ||
| 313 | let deadline = Instant::now() + Duration::from_secs(2); | ||
| 314 | while old.modified.values().any(|value| *value >= stamp()) { | ||
| 315 | assert!( | ||
| 316 | Instant::now() < deadline, | ||
| 317 | "source timestamps did not precede the test" | ||
| 318 | ); | ||
| 319 | std::thread::sleep(Duration::from_millis(5)); | ||
| 320 | } | ||
| 321 | let expected = onestore::replace_text( | ||
| 322 | &source, | ||
| 323 | sid, | ||
| 324 | oid, | ||
| 325 | 0..before.encode_utf16().count() as u32, | ||
| 326 | &after, | ||
| 327 | ) | ||
| 328 | .unwrap(); | ||
| 329 | let new = snapshot(&expected); | ||
| 330 | let baseline_path = format!("fault-{prefix}-{fixture}-baseline.one"); | ||
| 331 | create(&observer, &baseline_path, &source); | ||
| 332 | configure(&output, json!({"phase":format!("{fixture}-setup")})); | ||
| 333 | let baseline = Client::connect( | ||
| 334 | &proxied, | ||
| 335 | "agent", | ||
| 336 | Credentials::default(), | ||
| 337 | Duration::from_secs(5), | ||
| 338 | ) | ||
| 339 | .unwrap(); | ||
| 340 | let start = configure(&output, json!({"phase":format!("{fixture}-baseline")})); | ||
| 341 | let began = stamp(); | ||
| 342 | baseline | ||
| 343 | .commit_text( | ||
| 344 | &baseline_path, | ||
| 345 | &source, | ||
| 346 | sid, | ||
| 347 | oid, | ||
| 348 | 0..before.encode_utf16().count() as u32, | ||
| 349 | &after, | ||
| 350 | ) | ||
| 351 | .unwrap(); | ||
| 352 | let events = records(&output); | ||
| 353 | let mut occurrences = BTreeMap::new(); | ||
| 354 | let mut cuts = Vec::new(); | ||
| 355 | for event in &events[start..] { | ||
| 356 | let Some(direction) = event["direction"].as_str() else { | ||
| 357 | continue; | ||
| 358 | }; | ||
| 359 | if event["status"] == "0x103" { | ||
| 360 | continue; | ||
| 361 | } | ||
| 362 | let command = event["command"].as_u64().unwrap(); | ||
| 363 | let status = event["status"].as_str().map(str::to_owned); | ||
| 364 | let count = occurrences | ||
| 365 | .entry((direction.to_owned(), command, status.clone())) | ||
| 366 | .or_insert(0); | ||
| 367 | *count += 1; | ||
| 368 | cuts.push((direction.to_owned(), command, status, *count)); | ||
| 369 | } | ||
| 370 | assert!( | ||
| 371 | cuts.iter() | ||
| 372 | .any(|(direction, command, _, count)| direction == "response" | ||
| 373 | && *command == 7 | ||
| 374 | && *count == 3) | ||
| 375 | ); | ||
| 376 | assert!(published( | ||
| 377 | &snapshot(&observer.read(&baseline_path, 1 << 20).unwrap()), | ||
| 378 | &old, | ||
| 379 | &new, | ||
| 380 | began..=stamp() | ||
| 381 | )); | ||
| 382 | drop(baseline); | ||
| 383 | for (case, (direction, command, status, occurrence)) in cuts.iter().enumerate() { | ||
| 384 | let name = format!("{fixture}-{case:03}"); | ||
| 385 | let path = format!("fault-{prefix}-{name}.one"); | ||
| 386 | create(&observer, &path, &source); | ||
| 387 | configure(&output, json!({"phase":format!("{name}-setup")})); | ||
| 388 | let interrupted = Client::connect( | ||
| 389 | &proxied, | ||
| 390 | "agent", | ||
| 391 | Credentials::default(), | ||
| 392 | Duration::from_secs(5), | ||
| 393 | ) | ||
| 394 | .unwrap(); | ||
| 395 | let start = configure( | ||
| 396 | &output, | ||
| 397 | json!({"phase":name, "direction":direction, "cut":command, "status":status, "occurrence":occurrence}), | ||
| 398 | ); | ||
| 399 | let began = stamp(); | ||
| 400 | let error = interrupted | ||
| 401 | .commit_text( | ||
| 402 | &path, | ||
| 403 | &source, | ||
| 404 | sid, | ||
| 405 | oid, | ||
| 406 | 0..before.encode_utf16().count() as u32, | ||
| 407 | &after, | ||
| 408 | ) | ||
| 409 | .unwrap_err(); | ||
| 410 | assert_eq!( | ||
| 411 | interrupted.read(&path, 1 << 20).unwrap_err().kind(), | ||
| 412 | io::ErrorKind::NotConnected | ||
| 413 | ); | ||
| 414 | let events = records(&output); | ||
| 415 | assert_eq!( | ||
| 416 | events[start..] | ||
| 417 | .iter() | ||
| 418 | .filter(|event| event.get("cut").is_some()) | ||
| 419 | .count(), | ||
| 420 | 1 | ||
| 421 | ); | ||
| 422 | assert!( | ||
| 423 | !events | ||
| 424 | .iter() | ||
| 425 | .any(|event| event.get("trace_error").is_some()) | ||
| 426 | ); | ||
| 427 | let fresh = client(); | ||
| 428 | let deadline = Instant::now() + Duration::from_secs(5); | ||
| 429 | loop { | ||
| 430 | match fresh | ||
| 431 | .open(&path, true) | ||
| 432 | .and_then(|file| file.coordinate(&path, true)) | ||
| 433 | { | ||
| 434 | Ok(file) => { | ||
| 435 | file.close().unwrap(); | ||
| 436 | break; | ||
| 437 | } | ||
| 438 | Err(error) | ||
| 439 | if error.kind() == io::ErrorKind::WouldBlock | ||
| 440 | && Instant::now() < deadline => | ||
| 441 | { | ||
| 442 | std::thread::sleep(Duration::from_millis(5)) | ||
| 443 | } | ||
| 444 | Err(error) => panic!("{name}: retired session retained exclusion: {error}"), | ||
| 445 | } | ||
| 446 | } | ||
| 447 | let saved = fresh.read(&path, 1 << 20).unwrap(); | ||
| 448 | fs::write( | ||
| 449 | output.join("interrupted").join(format!("{name}.one")), | ||
| 450 | &saved, | ||
| 451 | ) | ||
| 452 | .unwrap(); | ||
| 453 | let visible = published(&snapshot(&saved), &old, &new, began..=stamp()); | ||
| 454 | match error.state { | ||
| 455 | CommitState::NotCommitted => assert!(!visible, "{name}"), | ||
| 456 | CommitState::Committed => assert!(visible, "{name}"), | ||
| 457 | CommitState::Unknown => {} | ||
| 458 | } | ||
| 459 | if !visible { | ||
| 460 | fresh | ||
| 461 | .commit_text( | ||
| 462 | &path, | ||
| 463 | &saved, | ||
| 464 | sid, | ||
| 465 | oid, | ||
| 466 | 0..before.encode_utf16().count() as u32, | ||
| 467 | &after, | ||
| 468 | ) | ||
| 469 | .unwrap(); | ||
| 470 | } | ||
| 471 | let saved = fresh.read(&path, 1 << 20).unwrap(); | ||
| 472 | assert!( | ||
| 473 | published(&snapshot(&saved), &old, &new, began..=stamp()), | ||
| 474 | "{name}: replay did not publish" | ||
| 475 | ); | ||
| 476 | let end = after.encode_utf16().count() as u32; | ||
| 477 | fresh | ||
| 478 | .commit_text(&path, &saved, sid, oid, end..end, suffix) | ||
| 479 | .unwrap(); | ||
| 480 | let recovered = fresh.read(&path, 1 << 20).unwrap(); | ||
| 481 | assert_eq!(text(&recovered).2, format!("{after}{suffix}")); | ||
| 482 | fs::write( | ||
| 483 | output.join("recovered").join(format!("{name}.one")), | ||
| 484 | recovered, | ||
| 485 | ) | ||
| 486 | .unwrap(); | ||
| 487 | results.push(json!({"case":name,"path":path,"direction":direction,"command":command,"status":status,"occurrence":occurrence, | ||
| 488 | "state":format!("{:?}",error.state),"visible":if visible {"after"} else {"before"}, | ||
| 489 | "retired_session_rejected":true,"exclusion_released":true,"fresh_commit_succeeded":true})); | ||
| 490 | fs::write( | ||
| 491 | output.join("results.json"), | ||
| 492 | serde_json::to_vec_pretty(&results).unwrap(), | ||
| 493 | ) | ||
| 494 | .unwrap(); | ||
| 495 | } | ||
| 496 | } | ||
| 497 | for state in ["NotCommitted", "Unknown", "Committed"] { | ||
| 498 | assert!(results.iter().any(|result| result["state"] == state)); | ||
| 499 | } | ||
| 500 | assert!( | ||
| 501 | results | ||
| 502 | .iter() | ||
| 503 | .any(|result| result["state"] == "Unknown" && result["visible"] == "before") | ||
| 504 | ); | ||
| 505 | assert!( | ||
| 506 | results | ||
| 507 | .iter() | ||
| 508 | .any(|result| result["state"] == "Unknown" && result["visible"] == "after") | ||
| 509 | ); | ||
| 510 | println!("{} message-loss cases passed", results.len()); | ||
| 511 | } | ||
crates/onestore/README.md+6-7| ... | @@ -23,10 +23,9 @@ OneNote verification of all 3200 editing intents. | ... | @@ -23,10 +23,9 @@ OneNote verification of all 3200 editing intents. |
| 23 | prototypes belong in sibling directories under `crates/` and depend on | 23 | prototypes belong in sibling directories under `crates/` and depend on |
| 24 | `onestore = { path = "../onestore" }`. The root manifest discovers these crates. | 24 | `onestore = { path = "../onestore" }`. The root manifest discovers these crates. |
| 25 | The consumer boundary and API tradeoffs are recorded in [API-AUDIT.md](../../evidence/API-AUDIT.md). | 25 | The consumer boundary and API tradeoffs are recorded in [API-AUDIT.md](../../evidence/API-AUDIT.md). |
| 26 | `crates/onestore-diagnostic` backs the [HTML diagnostic editor](../../evidence/DIAGNOSTIC.md). | 26 | `onestore-diagnostic` in the `notebook` crate backs the [HTML diagnostic editor](../../evidence/DIAGNOSTIC.md). |
| 27 | [`onestore-smb`](../onestore-smb/README.md) provides optional embedded network | 27 | [`notebook`](../notebook/README.md) provides notebook discovery, optional embedded |
| 28 | access; [`onestore-offline`](../onestore-offline/README.md) provides local | 28 | network access (feature `smb`) and local SQLite persistence and reconnect reconciliation for text, insertion and formatting. |
| 29 | SQLite persistence and reconnect reconciliation for text, insertion and formatting. | ||
| 30 | Shared native fixtures, specifications, evidence and Python/VM tools stay at the | 29 | Shared native fixtures, specifications, evidence and Python/VM tools stay at the |
| 31 | repository root; `fuzz/` remains an independent cargo-fuzz workspace. | 30 | repository root; `fuzz/` remains an independent cargo-fuzz workspace. |
| 32 | 31 | ||
| ... | @@ -143,7 +142,7 @@ Ambiguous ancestry, unsupported indentation transitions and unknown implicit | ... | @@ -143,7 +142,7 @@ Ambiguous ancestry, unsupported indentation transitions and unknown implicit |
| 143 | font/language inheritance reject before I/O. This is a logical join, so keyboard | 142 | font/language inheritance reject before I/O. This is a logical join, so keyboard |
| 144 | actions that only change list or indentation state remain separate operations. | 143 | actions that only change list or indentation state remain separate operations. |
| 145 | Generated fields, protected targets and unsupported run-data boundary changes are | 144 | Generated fields, protected targets and unsupported run-data boundary changes are |
| 146 | rejected before publication. The [offline crate](../onestore-offline/README.md) | 145 | rejected before publication. The [notebook crate](../notebook/README.md) |
| 147 | documents durable local operations and reconciliation. The | 146 | documents durable local operations and reconciliation. The |
| 148 | [document-writer acceptance](../../evidence/MILESTONE9.md#document-writer-and-offline-acceptance) | 147 | [document-writer acceptance](../../evidence/MILESTONE9.md#document-writer-and-offline-acceptance) |
| 149 | includes twelve mixed native/Rust clients, outages, lost replies and native revision retirement. | 148 | includes twelve mixed native/Rust clients, outages, lost replies and native revision retirement. |
| ... | @@ -161,7 +160,7 @@ cargo run --example create_notebook -- /tmp/one-demo 'Hello from Rust.' 'Example | ... | @@ -161,7 +160,7 @@ cargo run --example create_notebook -- /tmp/one-demo 'Hello from Rust.' 'Example |
| 161 | cargo run --example inventory -- /tmp/one-demo/synthetic.one | 160 | cargo run --example inventory -- /tmp/one-demo/synthetic.one |
| 162 | cargo run --example inspect -- /tmp/one-demo/synthetic.one | 161 | cargo run --example inspect -- /tmp/one-demo/synthetic.one |
| 163 | cargo run --example document -- /tmp/one-demo/synthetic.one /tmp/one-model | 162 | cargo run --example document -- /tmp/one-demo/synthetic.one /tmp/one-model |
| 164 | cargo build -p onestore-diagnostic | 163 | cargo build -p notebook --bin onestore-diagnostic |
| 165 | python3 tools/notebook_report.py /tmp/one-demo /tmp/one-report --timezone America/Los_Angeles | 164 | python3 tools/notebook_report.py /tmp/one-demo /tmp/one-report --timezone America/Los_Angeles |
| 166 | ``` | 165 | ``` |
| 167 | 166 | ||
| ... | @@ -249,7 +248,7 @@ The evidence covers transport failures, not physical server power loss or every | ... | @@ -249,7 +248,7 @@ The evidence covers transport failures, not physical server power loss or every |
| 249 | filesystem's lock implementation. | 248 | filesystem's lock implementation. |
| 250 | 249 | ||
| 251 | For shared network notebooks, use the optional | 250 | For shared network notebooks, use the optional |
| 252 | [`onestore-smb`](../onestore-smb/README.md) crate. It uses native share modes, | 251 | [`notebook::smb`](../notebook/README.md) module. It uses native share modes, |
| 253 | shared reader guards, writer exclusion and fresh pathname identity checks without | 252 | shared reader guards, writer exclusion and fresh pathname identity checks without |
| 254 | an OS-mounted share. Its [coordination acceptance](../../evidence/MILESTONE9.md) covers native | 253 | an OS-mounted share. Its [coordination acceptance](../../evidence/MILESTONE9.md) covers native |
| 255 | maintenance, mixed readers/writers, reconnects and uncertain publication. The | 254 | maintenance, mixed readers/writers, reconnects and uncertain publication. The |
fuzz/Cargo.lock+14-24| ... | @@ -577,6 +577,19 @@ dependencies = [ | ... | @@ -577,6 +577,19 @@ dependencies = [ |
| 577 | "libc", | 577 | "libc", |
| 578 | ] | 578 | ] |
| 579 | 579 | ||
| 580 | [[package]] | ||
| 581 | name = "notebook" | ||
| 582 | version = "0.1.0" | ||
| 583 | dependencies = [ | ||
| 584 | "onestore", | ||
| 585 | "rusqlite", | ||
| 586 | "serde", | ||
| 587 | "serde_json", | ||
| 588 | "sha2", | ||
| 589 | "tempfile", | ||
| 590 | "thiserror", | ||
| 591 | ] | ||
| 592 | |||
| 580 | [[package]] | 593 | [[package]] |
| 581 | name = "objc2" | 594 | name = "objc2" |
| 582 | version = "0.6.4" | 595 | version = "0.6.4" |
| ... | @@ -651,33 +664,10 @@ dependencies = [ | ... | @@ -651,33 +664,10 @@ dependencies = [ |
| 651 | "canvas", | 664 | "canvas", |
| 652 | "libfuzzer-sys", | 665 | "libfuzzer-sys", |
| 653 | "md5", | 666 | "md5", |
| 667 | "notebook", | ||
| 654 | "onestore", | 668 | "onestore", |
| 655 | "onestore-offline", | ||
| 656 | "serde_json", | ||
| 657 | "tempfile", | ||
| 658 | ] | ||
| 659 | |||
| 660 | [[package]] | ||
| 661 | name = "onestore-notebook" | ||
| 662 | version = "0.1.0" | ||
| 663 | dependencies = [ | ||
| 664 | "onestore", | ||
| 665 | "serde", | ||
| 666 | "thiserror", | ||
| 667 | ] | ||
| 668 | |||
| 669 | [[package]] | ||
| 670 | name = "onestore-offline" | ||
| 671 | version = "0.1.0" | ||
| 672 | dependencies = [ | ||
| 673 | "onestore", | ||
| 674 | "onestore-notebook", | ||
| 675 | "rusqlite", | ||
| 676 | "serde", | ||
| 677 | "serde_json", | 669 | "serde_json", |
| 678 | "sha2", | ||
| 679 | "tempfile", | 670 | "tempfile", |
| 680 | "thiserror", | ||
| 681 | ] | 671 | ] |
| 682 | 672 | ||
| 683 | [[package]] | 673 | [[package]] |
fuzz/Cargo.toml+1-1| ... | @@ -10,7 +10,7 @@ cargo-fuzz = true | ... | @@ -10,7 +10,7 @@ cargo-fuzz = true |
| 10 | [dependencies] | 10 | [dependencies] |
| 11 | libfuzzer-sys = "0.4" | 11 | libfuzzer-sys = "0.4" |
| 12 | onestore = { path = "../crates/onestore", features = ["protected"] } | 12 | onestore = { path = "../crates/onestore", features = ["protected"] } |
| 13 | onestore-offline = { path = "../crates/onestore-offline" } | 13 | notebook = { path = "../crates/notebook" } |
| 14 | canvas = { path = "../crates/canvas" } | 14 | canvas = { path = "../crates/canvas" } |
| 15 | tempfile = "3" | 15 | tempfile = "3" |
| 16 | md5 = "0.8.1" | 16 | md5 = "0.8.1" |
fuzz/fuzz_targets/offline_page.rs+1-1| ... | @@ -3,7 +3,7 @@ use libfuzzer_sys::fuzz_target; | ... | @@ -3,7 +3,7 @@ use libfuzzer_sys::fuzz_target; |
| 3 | 3 | ||
| 4 | #[path = "../../crates/onestore/tests/support/disk.rs"] | 4 | #[path = "../../crates/onestore/tests/support/disk.rs"] |
| 5 | mod disk; | 5 | mod disk; |
| 6 | #[path = "../../crates/onestore-offline/tests/support/page_schedule.rs"] | 6 | #[path = "../../crates/notebook/tests/support/page_schedule.rs"] |
| 7 | mod page_schedule; | 7 | mod page_schedule; |
| 8 | 8 | ||
| 9 | fuzz_target!(|input: &[u8]| page_schedule::run(input)); | 9 | fuzz_target!(|input: &[u8]| page_schedule::run(input)); |
fuzz/fuzz_targets/offline_paragraph.rs+1-1| ... | @@ -5,7 +5,7 @@ use onestore::{ | ... | @@ -5,7 +5,7 @@ use onestore::{ |
| 5 | Store, | 5 | Store, |
| 6 | document::{Document, Kind}, | 6 | document::{Document, Kind}, |
| 7 | }; | 7 | }; |
| 8 | use onestore_offline::{EditStatus, Operation, Remote, Replica}; | 8 | use notebook::{EditStatus, Operation, Remote, Replica}; |
| 9 | use std::{io, sync::LazyLock}; | 9 | use std::{io, sync::LazyLock}; |
| 10 | 10 | ||
| 11 | #[path = "../../crates/onestore/tests/support/disk.rs"] | 11 | #[path = "../../crates/onestore/tests/support/disk.rs"] |
fuzz/fuzz_targets/offline_tree.rs+1-1| ... | @@ -3,7 +3,7 @@ use libfuzzer_sys::fuzz_target; | ... | @@ -3,7 +3,7 @@ use libfuzzer_sys::fuzz_target; |
| 3 | 3 | ||
| 4 | #[path = "../../crates/onestore/tests/support/disk.rs"] | 4 | #[path = "../../crates/onestore/tests/support/disk.rs"] |
| 5 | mod disk; | 5 | mod disk; |
| 6 | #[path = "../../crates/onestore-offline/tests/support/tree_schedule.rs"] | 6 | #[path = "../../crates/notebook/tests/support/tree_schedule.rs"] |
| 7 | mod tree_schedule; | 7 | mod tree_schedule; |
| 8 | 8 | ||
| 9 | fuzz_target!(|input: &[u8]| tree_schedule::run(input)); | 9 | fuzz_target!(|input: &[u8]| tree_schedule::run(input)); |
tools/TESTING.md+1-1| ... | @@ -39,7 +39,7 @@ the run's commands, inputs, outputs and teardown evidence. | ... | @@ -39,7 +39,7 @@ the run's commands, inputs, outputs and teardown evidence. |
| 39 | | Native authoring and cold reopen | `native_runner.py --help` | Independent OneNote capture; exact expected page count when known; owned clone teardown | | 39 | | Native authoring and cold reopen | `native_runner.py --help` | Independent OneNote capture; exact expected page count when known; owned clone teardown | |
| 40 | | Mixed native/Rust/offline writers | `native_collaboration.py --help` | Recorded intents, durable receipts, independent server state and cold native comparison | | 40 | | Mixed native/Rust/offline writers | `native_collaboration.py --help` | Recorded intents, durable receipts, independent server state and cold native comparison | |
| 41 | | SMB directory pagination | `test_smb_directory.py --help` | Caller-owned Linux VM, native filesystem oracle, interrupted-page rejection | | 41 | | SMB directory pagination | `test_smb_directory.py --help` | Caller-owned Linux VM, native filesystem oracle, interrupted-page rejection | |
| 42 | | SMB publication and payload interruptions | Ignored tests in `onestore-smb` | Explicit `ONESTORE_SMB_*` lab inputs, retained protocol traces and independent recovery checks | | 42 | | SMB publication and payload interruptions | Ignored tests in `notebook` (feature `smb`) | Explicit `ONESTORE_SMB_*` lab inputs, retained protocol traces and independent recovery checks | |
| 43 | | Retained cache migration | Ignored `migrate_retained_cache_copy` test | New destination, unchanged source, images, intent IDs, attempts and receipts | | 43 | | Retained cache migration | Ignored `migrate_retained_cache_copy` test | New destination, unchanged source, images, intent IDs, attempts and receipts | |
| 44 | 44 | ||
| 45 | To compare an additional **already captured** notebook hierarchy without running | 45 | To compare an additional **already captured** notebook hierarchy without running |
tools/native_maintenance.py+1-1| ... | @@ -125,7 +125,7 @@ Sleep(1000) | ... | @@ -125,7 +125,7 @@ Sleep(1000) |
| 125 | hold.mkdir() | 125 | hold.mkdir() |
| 126 | config = json.loads((output / 'linux.json').read_text()) | 126 | config = json.loads((output / 'linux.json').read_text()) |
| 127 | with (hold / 'run.log').open('w') as guardian_log: | 127 | with (hold / 'run.log').open('w') as guardian_log: |
| 128 | guardian = subprocess.Popen(['cargo', 'test', '-p', 'onestore-smb', 'live_reader_hold', '--', '--ignored', '--nocapture'], | 128 | guardian = subprocess.Popen(['cargo', 'test', '-p', 'notebook', '--features', 'smb', 'live_reader_hold', '--', '--ignored', '--nocapture'], |
| 129 | cwd=ROOT, stdout=guardian_log, stderr=subprocess.STDOUT, | 129 | cwd=ROOT, stdout=guardian_log, stderr=subprocess.STDOUT, |
| 130 | env={**os.environ, 'ONESTORE_SMB_LAB': f'127.0.0.1:{config["samba_port"]}', | 130 | env={**os.environ, 'ONESTORE_SMB_LAB': f'127.0.0.1:{config["samba_port"]}', |
| 131 | 'ONESTORE_SMB_PATH': 'm6-collaboration/synthetic.one', 'ONESTORE_SMB_HOLD': str(hold)}) | 131 | 'ONESTORE_SMB_PATH': 'm6-collaboration/synthetic.one', 'ONESTORE_SMB_HOLD': str(hold)}) |
tools/smb_faults.py+3-3| ... | @@ -20,8 +20,8 @@ def run(output, server): | ... | @@ -20,8 +20,8 @@ def run(output, server): |
| 20 | output = output.resolve() | 20 | output = output.resolve() |
| 21 | output.mkdir(parents=True, exist_ok=False) | 21 | output.mkdir(parents=True, exist_ok=False) |
| 22 | sources = ['tools/smb_faults.py', 'tools/smb-proxy.py', 'Cargo.lock', | 22 | sources = ['tools/smb_faults.py', 'tools/smb-proxy.py', 'Cargo.lock', |
| 23 | 'crates/onestore-smb/src/lib.rs', 'crates/onestore-smb/src/tests.rs', | 23 | 'crates/notebook/src/smb/mod.rs', 'crates/notebook/src/smb/tests.rs', |
| 24 | 'crates/onestore-smb/src/tests/faults.rs', 'crates/onestore/src/commit.rs', | 24 | 'crates/notebook/src/smb/tests/faults.rs', 'crates/onestore/src/commit.rs', |
| 25 | 'crates/onestore/src/snapshot.rs'] | 25 | 'crates/onestore/src/snapshot.rs'] |
| 26 | for name in sources: | 26 | for name in sources: |
| 27 | target = output / 'harness' / name | 27 | target = output / 'harness' / name |
| ... | @@ -37,7 +37,7 @@ def run(output, server): | ... | @@ -37,7 +37,7 @@ def run(output, server): |
| 37 | config = linux_vm.load_instance(server) | 37 | config = linux_vm.load_instance(server) |
| 38 | (output / 'linux.json').write_text(json.dumps(config, indent=2)) | 38 | (output / 'linux.json').write_text(json.dumps(config, indent=2)) |
| 39 | with (output / 'test.log').open('w') as log: | 39 | with (output / 'test.log').open('w') as log: |
| 40 | process = subprocess.Popen(['cargo', 'test', '-p', 'onestore-smb', 'live_message_loss', '--', '--ignored', '--nocapture'], | 40 | process = subprocess.Popen(['cargo', 'test', '-p', 'notebook', '--features', 'smb', 'live_message_loss', '--', '--ignored', '--nocapture'], |
| 41 | cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, | 41 | cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, |
| 42 | env={**os.environ, 'ONESTORE_SMB_LAB': f'127.0.0.1:{config["samba_port"]}', | 42 | env={**os.environ, 'ONESTORE_SMB_LAB': f'127.0.0.1:{config["samba_port"]}', |
| 43 | 'ONESTORE_SMB_EVIDENCE': str(output / 'cases')}) | 43 | 'ONESTORE_SMB_EVIDENCE': str(output / 'cases')}) |
tools/test_offline_document_history.py+1-1| ... | @@ -141,7 +141,7 @@ class DocumentHistoryTests(unittest.TestCase): | ... | @@ -141,7 +141,7 @@ class DocumentHistoryTests(unittest.TestCase): |
| 141 | document_history(logs, 2) | 141 | document_history(logs, 2) |
| 142 | 142 | ||
| 143 | def test_production_cache_workload_matches_the_independent_history_model(self): | 143 | def test_production_cache_workload_matches_the_independent_history_model(self): |
| 144 | result = subprocess.run(['cargo', 'test', '--locked', '-p', 'onestore-offline', '--features', 'smb', | 144 | result = subprocess.run(['cargo', 'test', '--locked', '-p', 'notebook', '--features', 'smb', |
| 145 | '--example', 'smb_offline_client', | 145 | '--example', 'smb_offline_client', |
| 146 | 'tests::document_workload_retains_dependencies_and_receipts_across_reopen', | 146 | 'tests::document_workload_retains_dependencies_and_receipts_across_reopen', |
| 147 | '--', '--exact', '--nocapture'], | 147 | '--', '--exact', '--nocapture'], |
tools/test_smb_directory.py+3-3| ... | @@ -67,9 +67,9 @@ print(json.dumps(dict(path=root.name, entries=entries), ensure_ascii=False)) | ... | @@ -67,9 +67,9 @@ print(json.dumps(dict(path=root.name, entries=entries), ensure_ascii=False)) |
| 67 | time.sleep(.05) | 67 | time.sleep(.05) |
| 68 | env = dict(os.environ, ONESTORE_SMB_LAB=f'127.0.0.1:{port}', | 68 | env = dict(os.environ, ONESTORE_SMB_LAB=f'127.0.0.1:{port}', |
| 69 | ONESTORE_SMB_DIRECTORY=root, ONESTORE_SMB_DIRECTORY_ORACLE=str(output / 'oracle.json')) | 69 | ONESTORE_SMB_DIRECTORY=root, ONESTORE_SMB_DIRECTORY_ORACLE=str(output / 'oracle.json')) |
| 70 | test = 'tests::live_directory_interruption' if control else 'tests::live_directory' | 70 | test = 'tests::live_directory_interruption' if control else 'smb::tests::live_directory' |
| 71 | with (output / f'{name}.log').open('w') as result: | 71 | with (output / f'{name}.log').open('w') as result: |
| 72 | subprocess.run(['cargo', 'test', '-p', 'onestore-smb', test, '--', '--ignored', '--exact'], | 72 | subprocess.run(['cargo', 'test', '-p', 'notebook', '--features', 'smb', test, '--', '--ignored', '--exact'], |
| 73 | env=env, stdout=result, stderr=result, check=True, timeout=120) | 73 | env=env, stdout=result, stderr=result, check=True, timeout=120) |
| 74 | finally: | 74 | finally: |
| 75 | proxy.terminate() | 75 | proxy.terminate() |
| ... | @@ -84,7 +84,7 @@ print(json.dumps(dict(path=root.name, entries=entries), ensure_ascii=False)) | ... | @@ -84,7 +84,7 @@ print(json.dumps(dict(path=root.name, entries=entries), ensure_ascii=False)) |
| 84 | env = dict(os.environ, ONESTORE_SMB_LAB=f'127.0.0.1:{config["samba_port"]}', | 84 | env = dict(os.environ, ONESTORE_SMB_LAB=f'127.0.0.1:{config["samba_port"]}', |
| 85 | ONESTORE_SMB_DIRECTORY_ORACLE=str(output / 'oracle.json')) | 85 | ONESTORE_SMB_DIRECTORY_ORACLE=str(output / 'oracle.json')) |
| 86 | with (output / 'reconnected.log').open('w') as result: | 86 | with (output / 'reconnected.log').open('w') as result: |
| 87 | subprocess.run(['cargo', 'test', '-p', 'onestore-smb', 'tests::live_directory', '--', '--ignored', '--exact'], | 87 | subprocess.run(['cargo', 'test', '-p', 'notebook', '--features', 'smb', 'smb::tests::live_directory', '--', '--ignored', '--exact'], |
| 88 | env=env, stdout=result, stderr=result, check=True, timeout=120) | 88 | env=env, stdout=result, stderr=result, check=True, timeout=120) |
| 89 | print('reconnected: passed', flush=True) | 89 | print('reconnected: passed', flush=True) |
| 90 | 90 |