authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 01:40:12-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 01:40:12-07:00
log09ee1d6454d9862cd2b895fea88eee8541e6caaf
tree433e95082bcad582bc7baa4c4f7bdc1499850f98
parent1dea3f70f427bd13315c88c22cab8f539a791187
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

refactor: merge the notebook, offline, SMB and diagnostic crates into notebook

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.1

101 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]
15631563
1564[[package]]
1565name = "notebook"
1566version = "0.1.0"
1567dependencies = [
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]]
1565name = "num-traits"1581name = "num-traits"
1566version = "0.2.19"1582version = "0.2.19"
...@@ -1923,54 +1939,6 @@ dependencies = [...@@ -1923,54 +1939,6 @@ dependencies = [
1923 "zeroize",1939 "zeroize",
1924]1940]
19251941
1926[[package]]
1927name = "onestore-diagnostic"
1928version = "0.1.0"
1929dependencies = [
1930 "onestore",
1931 "onestore-notebook",
1932 "serde",
1933 "serde_json",
1934]
1935
1936[[package]]
1937name = "onestore-notebook"
1938version = "0.1.0"
1939dependencies = [
1940 "onestore",
1941 "onestore-smb",
1942 "serde",
1943 "serde_json",
1944 "tempfile",
1945 "thiserror 2.0.20",
1946 "zeroize",
1947]
1948
1949[[package]]
1950name = "onestore-offline"
1951version = "0.1.0"
1952dependencies = [
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]]
1965name = "onestore-smb"
1966version = "0.1.0"
1967dependencies = [
1968 "onestore",
1969 "serde_json",
1970 "smb2",
1971 "tokio",
1972]
1973
1974[[package]]1942[[package]]
1975name = "orbclient"1943name = "orbclient"
1976version = "0.3.55"1944version = "0.3.55"
crates/notebook/Cargo.toml created+37
...@@ -0,0 +1,37 @@
1[package]
2name = "notebook"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[features]
8smb = ["dep:smb2", "dep:tokio"]
9protected = ["onestore/protected", "dep:zeroize"]
10
11[dependencies]
12onestore = { path = "../onestore" }
13rusqlite = { version = "=0.40.2", features = ["bundled", "backup"] }
14thiserror = "2"
15serde = { version = "1", features = ["derive"] }
16serde_json = "1"
17tempfile = "3"
18sha2 = "0.11"
19smb2 = { version = "=0.21.0", optional = true }
20tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true }
21zeroize = { version = "1.9.0", optional = true }
22
23[dev-dependencies]
24serde_json = "1"
25tempfile = "3"
26
27[[example]]
28name = "smb_offline_client"
29required-features = ["smb"]
30
31[[example]]
32name = "smb_reconnect_client"
33required-features = ["smb"]
34
35[[example]]
36name = "smb_concurrent_client"
37required-features = ["smb"]
crates/notebook/README.md created+415
...@@ -0,0 +1,415 @@
1# notebook
2
3The application-facing crate: notebook discovery, a durable local replica with
4reconnect reconciliation, external-asset caching, recovery export and optional
5embedded SMB access. Durable local editing for a OneNote file: the current
6foundation stores a complete working image and typed editing intents in a local
7SQLite database. `sync_once` provides a reconciliation step and `start_sync` owns
8automatic polling and reconnects. Local success does not
9acknowledge publication to a shared notebook.
10
11```no_run
12use onestore::ExGuid;
13use 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.
17let cache = Replica::create(path, source)?;
18let snapshot = cache.snapshot()?;
19let local_id = cache.edit_text(&snapshot, space, text, 0..0, "Offline edit ")?;
20drop(cache);
21
22let reopened = Replica::open(path)?;
23let current = reopened.snapshot()?;
24let pending = reopened.pending()?;
25assert_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
31intent identities. `Insertion::with_formatting` queues text and
32its styles as one intent and one publication. Keep that value across retries; its `text_object()` identifies
33the 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
40outline can precede its paragraphs and their later edits. Missing anchors or
41existing insertion identities preserve a conflict and the complete local image.
42
43`create_page` accepts the core `PageCreation` intent and queues its page space
44and section entry as one publication. Subsequent outlines and title edits use
45the intent's stable identities immediately after local acknowledgement.
46Independent page additions rebase against the current section order; duplicate
47titles remain distinct. An unavailable or no-longer-leading insertion anchor
48produces `StructureChanged`. `rebase_page_creation_conflict(id, local, remote,
49before)` reviews a replacement anchor against both cache images while retaining
50the new page and dependent object identities. Existing page identities require
51reconciliation; a matching page alone does not establish a receipt.
52
53`pages(snapshot, edits)` queues a slice of core `PageEdit` intents as one atomic
54publication. `PagePosition::Keep` preserves remote movement during indentation-only
55edits. Every requested level remains explicit: a competing remote level produces
56`StructureChanged` unless it already matches the requested level. Moves compare
57the selected page's position relative to surviving observed pages; an unchanged
58or already-satisfied position can proceed, and new remote pages remain present.
59Indistinguishable competing moves retain a conflict and the complete local image.
60
61`rebase_pages_conflict(id, local, remote, edits)` reviews the batch against both
62cache images. Use `PageEdit::reposition(position, level)` on the retained intents
63to revise anchors or indentation; replacing page or allocated series identities
64is rejected. Dependent edits remain queued. Schema 11 adds the durable batch;
65schema 10 migration preserves existing multi-space publication evidence unchanged.
66Attempt evidence includes every changed space plus the receipt space when that
67space is unchanged. An existing section revision alone cannot confirm a page edit.
68The [offline page corpus](../../corpus/page-lifecycle/offline-edits/README.md)
69contains native comparisons, reproduction commands and stateful sanitizer seeds.
70
71`split` and `join` accept the core `ParagraphSplit` and `ParagraphJoin` intents.
72Splits retain allocated identities when the original UTF-16 boundary rebases;
73dependent edits can address `ParagraphSplit::text_object()` immediately after
74local acknowledgement. Both operations retain observed parent paths, children,
75indentation, paragraph/list state and tags. Changed structure produces
76`StructureChanged`. Remote text changes must leave unambiguous boundary mappings;
77current remote character styles are preserved. Reconciliation does not silently
78discard 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
81allocated identities. `rebase_join_conflict(id, local, remote)` reviews the original
82join against current images. It rejects a change in which text identity survives,
83because dependent edits still address that identity. As with other conflict
84reviews, stale images, pending operations and uncertain attempts cannot be rebased.
85An uncertain structural operation requires its original attempted revision for
86confirmation; 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
90placements for the oldest insertion conflict. They preserve creation time, text,
91author and object identities, keeping later edits attached to their original targets.
92The images must still match the cache; the new placement must be valid in the remote
93image. Paragraph intents cannot become outlines or vice versa. Pending edits and
94uncertain publication attempts cannot be repositioned through conflict review.
95
96```no_run
97use onestore::{ExGuid, Insertion, TextAttribute};
98use notebook::{EditStatus, Replica};
99# fn add_outline(cache: &Replica, space: ExGuid, page: ExGuid)
100# -> Result<(), Box<dyn std::error::Error>> {
101let outline = Insertion::outline(page, 144.0, 216.0, "Offline outline", "Author")?
102 .with_formatting(0..7, &[TextAttribute::Bold(true)])?;
103let id = cache.insert(&cache.snapshot()?, space, &outline)?;
104if 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
116retains the observed text and selected attribute values. Remote text changes must
117leave an unambiguous range; independent remote attributes merge, while competing
118values preserve `FormattingChanged`. A remote value that already matches the
119requested value is accepted. Enabling superscript or subscript also checks the
120opposite attribute that the operation clears. If the remote image already satisfies
121the whole operation, guarded confirmation still precedes a durable receipt.
122
123`outline` accepts a target object and the core `onestore::OutlineEdit`: outline
124position/width or a paragraph's saved collapse default. It retains the target's
125ancestry and the properties the operation changes. Independent content, formatting
126and layout properties merge; competing selected values produce `LayoutChanged`,
127and changed ancestry produces `StructureChanged`. Width changes also check the
128reserved wrapping width that they clear. Missing targets preserve the local branch.
129`rebase_layout_conflict(id, local, remote)` explicitly reviews the original change
130against both current images, preserving dependent intents. An uncertain layout
131attempt requires its original revision; converged values cannot establish its receipt.
132
133`tree` accepts the core `onestore::TreeEdit`. Moves preserve independent remote
134text, formatting and descendants. A competing ancestor, indentation or sibling
135crossing produces `StructureChanged`; unrelated sibling insertions/deletions can
136merge. An anchor must remain a direct child of the requested destination. An
137already satisfied move still requires guarded confirmation before acknowledgement.
138Deletion compares the selected raw property graph, including referenced styles,
139tags, unknown fields and internal attachments. Changed content produces
140`ContentChanged`; property order, CompactID numbering and modification timestamps
141do not affect that comparison. Immutable records compare by content, and empty
142child lists compare equally to absent child lists. Mutable object identities
143remain significant. Missing targets retain `TargetUnavailable`.
144`rebase_tree_conflict(id, local, remote)` reviews the original move/deletion against
145both current images. An emptied cell's replacement paragraph/text identities must
146remain the same before replay or review; later queued edits keep their targets.
147Uncertain tree attempts require their original revision for confirmation, even
148when an independent move or deletion has the same visible effect.
149
150Recognized earlier caches migrate transactionally to version ten. Publication
151attempts retain every changed space's revision; legacy attempts retain their
152single-space evidence. Earlier recovery archives remain readable without migration.
153Version-eight
154deletion observations retain their original identity-sensitive preconditions;
155explicit conflict review upgrades them to the immutable-content comparison.
156The migration retains images, local IDs, publication attempts, conflicts,
157receipts and the autoincrement sequence; it does not reuse acknowledged IDs when
158the pending queue is empty.
159
160Share one `Replica` between application threads. Each edit compares its supplied
161snapshot under the cache transaction; stale snapshots return `Io(ResourceBusy)`.
162The intent and its resulting image commit together. No-op edits return `None`.
163Keep the cache on a local filesystem: the connection holds exclusive ownership
164between transactions, and a second open fails busy. No network wait occurs in a
165local edit. After a database error, reopen and inspect the durable state before
166retrying.
167
168`sync_once(&mut remote)` processes the oldest pending edit through a `Remote`
169implementation, returning its ID and `EditStatus`. A durable `Published` receipt
170survives reopening. Publication attempts are recorded before network I/O; a retained
171attempt requires every recorded revision to remain present, followed by comparison,
172flushing and refreshed header version
173metadata before acknowledgement. If a formatting attempt's revision is missing,
174the complete requested effect can instead be confirmed on a uniquely aligned
175range; its receipt identifies that confirmed current revision. Otherwise the
176missing attempt remains `AwaitingConfirmation`. Neither path replays an uncertain
177publication. Overlapping or ambiguous edits retain `Conflict` status, their complete
178local image and the last observed remote image returned by `remote_snapshot`.
179Transport errors return `Error::Remote` or `Error::RemoteIo`; inspect `status(id)`
180after the error to distinguish a retained attempt from a pending edit or receipt.
181The error return does not roll back a locally acknowledged intent.
182
183Rebasing accepts only character mappings shared by every minimum insertion/deletion
184alignment. UTF-16 ranges must preserve Unicode scalar boundaries. A bounded
185alignment search also leaves a conflict when it cannot establish a unique mapping.
186The current operation processes one queue head; while edits remain, `snapshot`
187preserves the complete local working image. An empty queue can refresh from the
188remote image. Synchronization holds a separate owner lock, so local edits can
189continue during network waits; competing synchronization calls return `WouldBlock`.
190
191`rebase_conflict(id, local, remote, range)` lets a caller explicitly place the
192oldest conflicting text or formatting intent at a reviewed UTF-16 range in the remote image.
193The supplied images must still match `snapshot()` and `remote_snapshot()`.
194It preserves the original replacement or requested attributes, intent ID, complete local working image
195and every later intent; the selected range and remote paragraph become the
196intent's new comparison base in one local transaction. Formatting also captures
197the reviewed attribute values as its new precondition. This operation performs
198no network I/O, clears the conflict to `Pending`, and wakes the worker.
199Publication still reads the latest remote image and uses exact guarded comparison;
200another overlapping remote edit can produce a new conflict. An uncertain attempt
201cannot be rebased, and selecting text that already equals the replacement does
202not create a publication acknowledgement.
203
204With 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
206object space, which survives the tested native compaction that replaces the file ID.
207
208An `Arc<Replica>` can own one background worker. Supply a connection factory, poll
209interval and observer; successful publications drain immediately, durable local
210edits wake the worker, and `wake()` requests an immediate reachability retry.
211Transport failures discard the old connection and retry through the factory;
212Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy`
213reuse the connection. Contended `NotCommitted` operations use randomized backoff,
214capped at one second, to separate competing retry cycles. Cancellation interrupts this delay; local wake notifications
215remain coalesced until its end.
216`RemoteIo` distinguishes connection/read failures from
217local `Io` errors. Cache/document errors stop the worker. Observers receive every
218attempt's result on the worker thread, including unchanged conflict/uncertain
219statuses; 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>> {
225use notebook::SmbRemote;
226use notebook::smb::{Client, Credentials};
227use std::time::Duration;
228
229let 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.
243worker.stop()?;
244# Ok(())
245# }
246```
247
248`stop()` cancels future steps and joins the worker, returning a fatal cache error
249or worker panic. Dropping it requests cancellation without waiting. An in-flight
250step completes before releasing ownership; a replacement worker cannot start
251while the old one still owns the replica. Remote operations and callbacks must
252have bounded execution times if shutdown latency matters. A dropped worker can
253briefly retain the cache; use `stop()` before requiring an immediate reopen.
254Cancellation and application restart retain pending edits and publication attempts.
255Credentials belong to the factory, not the cache database.
256
257Creation refuses existing paths. Opening recognizes the application identity and
258schema version, validates database integrity and both notebook images, and rejects
259unsupported journal modes without converting them. Failed initialization preserves
260the file for inspection. SQLite uses DELETE journaling, EXTRA synchronization and
261fullfsync; each required setting is queried back.
262
263The cache and its pending intents contain notebook content. The core `onestore`
264crate stays independent of SQLite and network runtimes. Device and simulator
265examples compile and link for iOS; recorded process-interruption tests do not
266establish physical power-loss durability. Evidence is tracked in [Milestone 9](../../evidence/MILESTONE9.md).
267
268The SMB-enabled `smb_offline_client` example is an owned-lab workload for
269`tools/native_collaboration.py --offline --embedded-smb`. It separates local
270acknowledgements, remote publication attempts, persisted receipts and cache reopen
271checks. Its append-specific conflict review policy lives in the test client;
272the 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
277queue, uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one
278SQLite snapshot. It refuses existing destinations and leaves the live queue
279unchanged. Export to a local directory from a background thread: copying holds
280the cache mutex while capturing the database. Failure after the final rename can
281leave a complete archive at the requested path; it never acknowledges a remote
282edit. Archives contain notebook content and use a separate database identity, so
283`Replica::open` rejects them as writable caches.
284
285```no_run
286use notebook::{Recovery, Replica};
287# fn example(cache: &Replica) -> Result<(), Box<dyn std::error::Error>> {
288cache.export_recovery("review.sqlite")?;
289let review = Recovery::open("review.sqlite")?;
290let counts = review.summary()?;
291let local = review.snapshot()?;
292let remote = review.remote_snapshot()?;
293let pending = review.pending()?;
294let 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
302counts and byte sizes without notebook text, paths, authors or credentials.
303Opening an archive validates its schema and images without migration. A recovery
304archive 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
309payload through `notebook::discover::Source` and durably caches its exact bytes.
310The 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
312SMB sources use the same API. Downloads release the cache mutex during network
313I/O and recheck the reference before committing; edits can continue meanwhile.
314
315`cached_asset(filename, limit)` reads previously downloaded bytes without network
316access. `None` means never downloaded; `Some(Vec::new())` is a downloaded empty
317payload. Each read checks the stored SHA-256 and enforces the byte limit before
318loading the payload. Cache contents describe the prior download, not current
319server reachability or presence. A failed fetch returns its error without
320silently substituting cached bytes. Different bytes for an already cached file
321identity return `AssetChanged` and preserve the previous download.
322
323Downloads do not change pending edits, publication attempts or receipts. Recovery
324archives include cached media and expose the same bounded `cached_asset` lookup.
325Schema-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
330root, keeping directory traversal out of the single-file storage parser.
331
332Discovery returns ordered sections and nested groups with file identities and
333share-relative paths. Section display-name overrides remain distinct from file
334names. TOC references whose identities are absent from the directory remain
335inspectable; a cached filename never substitutes for an identity match.
336Reserved `_onefiles` directories are excluded from section-group traversal.
337Encrypted sections and valid storage with an unreadable document graph retain
338their identity as `Locked` or `Unreadable`, without being presented as empty pages.
339Malformed storage, failed reads and ambiguous identities reject the discovery.
340
341Each file read must be a consistent, bounded snapshot. The result is an
342observation across multiple files, not an atomic notebook transaction or
343authorization to publish an edit. Refresh rejects observed topology changes and
344duplicate physical files with the same logical identity. Retain the last accepted
345catalog 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
348selected section's sibling `_onefiles` folder and returns exact bounded bytes.
349Missing files, permissions and size failures retain their I/O error kinds; an
350empty payload is a successful empty buffer. Embedded payloads remain available
351directly from the core document model.
352
353## SMB (feature `smb`)
354
355`notebook::smb` provides blocking SMB access for OneNote sections and
356table-of-contents files. The core `onestore` crate remains independent of network runtimes. This is an
357experimental Rust API with native interoperability evidence in the repository's
358[Milestone 9](../../evidence/MILESTONE9.md).
359
360```no_run
361use notebook::smb::{Client, Credentials};
362use std::time::Duration;
363
364let client = Client::connect(
365 "server:445",
366 "notes",
367 Credentials { username: "user", password: "password", domain: "" },
368 Duration::from_secs(5),
369)?;
370let snapshot = client.read("Personal/Video.one", 64 * 1024 * 1024)?;
371let store = onestore::Store::parse(&snapshot)?;
372let revisions = onestore::RevisionIndex::parse(&store)?;
373let document = onestore::document::Document::parse(&revisions)?;
374# Ok::<(), Box<dyn std::error::Error>>(())
375```
376
377Paths are relative to the share. The read limit bounds the complete physical
378snapshot. Call from a background thread outside a Tokio runtime. Use identities
379from 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
382and persist the intended revision identity before `Client::commit_prepared`.
383`Client::confirm_snapshot` compares and flushes an observed image, then refreshes
384its header version metadata without adding a revision. The caller must first
385establish which intents that image contains and reread before another commit.
386
387Readers use shared native guards while writers publish under native write-open
388and byte-lock exclusion. Maintenance is excluded during each operation; pathname
389identity is checked after acquiring the guards. Connection loss retires the
390client. Reconnect for subsequent operations, and reconcile an `Unknown` edit
391before retrying it. The transport does not automatically replay requests.
392
393`Client::read_dir(path, entry_limit)` enumerates a directory, including the share
394root with an empty path. It follows every response page and returns no partial
395list on interruption, entry-limit overflow or close failure. Entries retain exact
396Unicode names, observed sizes and MS-FSCC attributes, including directory/reparse
397flags. Concurrent directory changes are not an atomic snapshot; repeated names
398are rejected with `ResourceBusy`. Notebook identities come from the files, not
399directory names or sizes. Missing paths, denied access and non-directory paths
400have distinct I/O error kinds.
401
402`Client::read_asset(path, byte_limit)` reads an external payload under a read-only
403share handle that excludes writes and deletion. Empty files succeed; limits,
404interrupted reads and failed close never return partial bytes. This payload read
405does 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
408Linux lab VM against its filesystem listing and interrupts directory requests,
409responses and close. It creates synthetic files in that VM; the caller retains
410responsibility for VM teardown. The parser also has bounded-record/truncation
411tests independent of the server.
412
413Device and simulator builds link for iOS. Native acceptance uses disposable
414OneNote 2010 clients and Samba; it does not establish on-device execution or
415physical power-loss durability.
crates/notebook/examples/cache_probe.rs created+238
...@@ -0,0 +1,238 @@
1use notebook::Replica;
2use onestore::{
3 ExGuid, Insertion, RevisionIndex, Store, TextAttribute,
4 document::{Document, Kind},
5};
6use std::{
7 io::{self, BufRead, Write},
8 path::Path,
9};
10
11fn 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
33fn payload(operation: u64, size: usize) -> String {
34 format!("{operation}:🦀{}", "x".repeat(size))
35}
36
37fn 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 @@
1use notebook::discover::{Limits, Local, discover};
2
3fn 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 @@
1use onestore::{
2 FileDataReference, RevisionIndex, Store,
3 document::{Document, Kind},
4};
5use std::{
6 borrow::Cow,
7 collections::BTreeSet,
8 env, fs,
9 io::{self, BufWriter, Write},
10 path::{Path, PathBuf},
11};
12
13fn 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
23fn 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.
2use onestore::{FileDataReference, RevisionIndex, Store};
3use std::{fs, path::PathBuf};
4fn 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 @@
1mod support {
2 pub mod view;
3}
4use support::view::view;
5
6use notebook::{EditStatus, Remote, Replica};
7use onestore::{CommitError, CommitIo, PreparedEdit};
8use serde_json::json;
9use std::{
10 env,
11 fs::{self, File, OpenOptions},
12 io::{self, Write},
13 os::unix::fs::FileExt,
14 path::Path,
15};
16
17fn 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
29struct Disk {
30 file: File,
31 writes: usize,
32 flushes: usize,
33}
34
35impl 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
59impl 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
84fn 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
108fn 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"]
2mod concurrent;
3
4use notebook::smb::{Client, Credentials};
5use std::{env, time::Duration};
6
7fn 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"]
2mod concurrent;
3
4use notebook::smb::{Client, Credentials};
5use notebook::{EditStatus, Error, Remote, Replica, SmbRemote};
6use onestore::{
7 CommitError, CommitState, ExGuid, Insertion, ParagraphJoin, ParagraphSplit, PreparedEdit,
8 RevisionIndex, Store, TextAttribute, TreeEdit, document::Document,
9};
10use serde_json::json;
11use 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
20mod support {
21 pub mod view;
22}
23use concurrent::{DocumentView, document_view};
24use support::view::view;
25
26impl 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
44const 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
57fn now() -> u128 {
58 SystemTime::now()
59 .duration_since(UNIX_EPOCH)
60 .unwrap()
61 .as_micros()
62}
63
64#[derive(Clone)]
65enum Pause {
66 Outage(PathBuf),
67 FormatReply(PathBuf),
68}
69
70struct Traced<R> {
71 remote: R,
72 before: Option<(String, Option<DocumentView>)>,
73 pause: Option<Pause>,
74 documents: bool,
75}
76
77impl<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
261fn 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.
285fn 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
298fn 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
454fn 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]
672fn 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"]
704mod disk;
705
706#[cfg(test)]
707mod 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"]
2mod concurrent;
3
4use notebook::smb::{Client, Credentials};
5use onestore::{
6 CommitError, CommitState, ExGuid, RevisionIndex, Store,
7 document::{Document, Kind},
8};
9use serde_json::json;
10use std::{
11 cell::RefCell,
12 env, io, thread,
13 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
14};
15
16fn 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.
34fn 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
50fn 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]
251fn 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 @@
1use onestore::{
2 ExGuid, RevisionIndex, Store,
3 document::{Document, Kind},
4};
5
6pub struct View {
7 pub space: ExGuid,
8 pub object: ExGuid,
9 pub revision: ExGuid,
10 pub text: String,
11}
12
13pub 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 @@
1use super::*;
2use onestore::FileDataReference;
3use rusqlite::OptionalExtension;
4use sha2::{Digest, Sha256};
5
6impl 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
76pub(crate) fn key(filename: &str) -> Result<String> {
77 format!("<file>{filename}").parse::<FileDataReference>()?;
78 Ok(filename.to_ascii_lowercase())
79}
80
81fn 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
101pub(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 @@
1use onestore::{ExGuid, Insertion, PreparedEdit, TextAttribute};
2use serde::Deserialize;
3use serde_json::{Value, json};
4use std::{
5 env, fs,
6 io::{self, Write},
7};
8
9#[derive(Deserialize)]
10#[serde(deny_unknown_fields)]
11struct Edit {
12 space: ExGuid,
13 object: ExGuid,
14 action: Action,
15}
16
17#[derive(Deserialize)]
18#[serde(tag = "type", deny_unknown_fields)]
19enum 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
43fn 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
116fn 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
3use onestore::{
4 FileType, RevisionIndex, Store,
5 document::{Document, Kind},
6};
7use serde::Serialize;
8use std::{
9 collections::{BTreeMap, BTreeSet},
10 io,
11};
12
13mod source;
14pub use source::Local;
15#[cfg(feature = "smb")]
16pub use source::Smb;
17
18#[derive(Debug, Serialize)]
19pub 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)]
27pub 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)]
37pub 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)]
45pub 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)]
53pub 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)]
61pub enum EntryKind {
62 File,
63 Directory,
64 Other,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
68pub 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.
74pub 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.
87pub 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
121pub struct Limits {
122 pub entries: usize,
123 pub bytes_per_file: usize,
124 pub depth: usize,
125}
126
127#[derive(Debug, thiserror::Error)]
128pub 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.
157pub 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
163fn 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
402fn component(name: &str) -> bool {
403 !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
404}
405
406fn 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 @@
1use super::{Entry, EntryKind, Source, component};
2use std::{
3 io,
4 path::{Path, PathBuf},
5};
6
7/// A canonical local root; symbolic-link entries are not traversed.
8pub struct Local {
9 root: PathBuf,
10}
11
12impl 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
33impl 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.
67pub struct Smb<'a> {
68 client: &'a crate::smb::Client,
69 root: String,
70}
71
72#[cfg(feature = "smb")]
73impl<'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")]
95impl 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 @@
1use super::*;
2use onestore::TextAttribute;
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5use std::collections::BTreeMap;
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8#[serde(deny_unknown_fields)]
9struct 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)]
17pub 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
25fn 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
61fn 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
104impl 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
120impl 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
4pub mod discover;
5#[cfg(feature = "smb")]
6pub mod smb;
7
8use onestore::{
9 ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store,
10 document::{Document, Kind},
11};
12use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};
13use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration};
14
15mod assets;
16mod formatting;
17mod outline;
18mod pages;
19mod paragraph;
20mod rebase;
21mod recovery;
22mod schema;
23mod tree;
24pub use formatting::FormatEdit;
25pub use outline::OutlineEdit;
26pub use pages::PageEdits;
27pub use paragraph::{JoinEdit, SplitEdit};
28pub use recovery::{Recovery, RecoverySummary};
29pub use tree::TreeEdit;
30mod sync;
31pub use sync::{ConflictKind, EditStatus, Remote};
32mod worker;
33#[cfg(feature = "smb")]
34pub use smb::SmbRemote;
35pub use worker::SyncWorker;
36
37#[derive(Debug, thiserror::Error)]
38pub 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
55type Result<T> = std::result::Result<T, Error>;
56
57const APPLICATION_ID: u32 = 0x4f4e454f;
58const 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)]
63pub 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)]
72pub 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)]
86pub 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.
94pub struct Replica {
95 connection: Mutex<Connection>,
96 synchronization: Mutex<()>,
97 worker: Mutex<std::sync::Weak<worker::Signal>>,
98}
99
100impl 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
305fn 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
334fn 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
348fn 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
363fn 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
386fn 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
415fn 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 @@
1use super::*;
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6#[serde(deny_unknown_fields)]
7struct 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)]
15pub struct OutlineEdit {
16 pub object: ExGuid,
17 pub change: onestore::OutlineEdit,
18 observed: Observed,
19}
20
21fn 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
61impl 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
75impl 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 @@
1use super::*;
2use onestore::{PageEdit, PagePosition};
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6type PageOrder = Vec<(ExGuid, u32)>;
7
8/// An atomic page batch with its observed order and indentation.
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct PageEdits {
12 pub edits: Vec<PageEdit>,
13 observed: PageOrder,
14}
15
16fn observe(source: &[u8]) -> Result<Option<(ExGuid, PageOrder)>> {
17 let store = Store::parse(source)?;
18 let index = RevisionIndex::parse(&store)?;
19 let document = Document::parse(&index)?;
20 let mut pages = Vec::new();
21 for (sid, _) in document.pages()? {
22 let view = document.active(sid)?;
23 let Some(metadata) = view.roots.get(&2).and_then(|id| view.nodes.get(id)) else {
24 return Ok(None);
25 };
26 let Kind::Metadata { level, .. } = metadata.kind else {
27 return Ok(None);
28 };
29 pages.push((sid, level.unwrap_or(1)));
30 }
31 Ok(Some((document.root, pages)))
32}
33
34fn positions(pages: &[(ExGuid, u32)]) -> Result<BTreeMap<ExGuid, (usize, u32)>> {
35 let mut positions = BTreeMap::new();
36 for (at, (sid, level)) in pages.iter().enumerate() {
37 if sid.guid == [0; 16]
38 || !(1..=3).contains(level)
39 || positions.insert(*sid, (at, *level)).is_some()
40 {
41 return Err(io::Error::new(
42 io::ErrorKind::InvalidData,
43 "Page observations need unique identities and valid indentation",
44 )
45 .into());
46 }
47 }
48 Ok(positions)
49}
50
51impl Replica {
52 /// Durably queues the complete page batch using the supplied local snapshot.
53 pub fn pages(&self, source: &[u8], edits: &[PageEdit]) -> Result<Option<u64>> {
54 let (space, batch, prepared) = PageEdits::capture(source, edits)?;
55 self.record(source, space, Operation::Pages(batch), &prepared)
56 }
57}
58
59impl PageEdits {
60 fn capture<'a>(
61 source: &'a [u8],
62 edits: &[PageEdit],
63 ) -> Result<(ExGuid, Self, PreparedEdit<'a>)> {
64 let prepared = PreparedEdit::pages(source, edits)?;
65 let (space, observed) = observe(source)?.ok_or_else(|| {
66 io::Error::new(
67 io::ErrorKind::InvalidData,
68 "Prepared page edits have no page metadata",
69 )
70 })?;
71 positions(&observed)?;
72 Ok((
73 space,
74 Self {
75 edits: edits.to_vec(),
76 observed,
77 },
78 prepared,
79 ))
80 }
81
82 pub(crate) fn prepare<'a>(
83 &self,
84 source: &'a [u8],
85 space: ExGuid,
86 ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> {
87 let Some((root, current)) = observe(source)? else {
88 return Ok(Err(ConflictKind::TargetUnavailable));
89 };
90 if root != space {
91 return Ok(Err(ConflictKind::TargetUnavailable));
92 }
93 let before = positions(&self.observed)?;
94 let current = positions(&current)?;
95 for edit in &self.edits {
96 let Some((_, original_level)) = before.get(&edit.space()) else {
97 return Ok(Err(ConflictKind::TargetUnavailable));
98 };
99 let Some((_, current_level)) = current.get(&edit.space()) else {
100 return Ok(Err(ConflictKind::TargetUnavailable));
101 };
102 if current_level != original_level && *current_level != edit.level() {
103 return Ok(Err(ConflictKind::StructureChanged));
104 }
105 }
106 let prepared = match PreparedEdit::pages(source, &self.edits) {
107 Ok(prepared) => prepared,
108 Err(_) => return Ok(Err(ConflictKind::StructureChanged)),
109 };
110 let (_, wanted) = observe(prepared.as_bytes())?.ok_or_else(|| {
111 io::Error::new(
112 io::ErrorKind::InvalidData,
113 "Prepared page edits lost page metadata",
114 )
115 })?;
116 let wanted = positions(&wanted)?;
117 for edit in &self.edits {
118 if edit.position() == PagePosition::Keep {
119 continue;
120 }
121 let sid = edit.space();
122 let peers = before
123 .keys()
124 .filter(|id| **id != sid && current.contains_key(id));
125 let mut unchanged = true;
126 let mut satisfied = true;
127 for id in peers {
128 let observed = current[id].0 < current[&sid].0;
129 unchanged &= observed == (before[id].0 < before[&sid].0);
130 satisfied &= observed == (wanted[id].0 < wanted[&sid].0);
131 }
132 if !unchanged && !satisfied {
133 return Ok(Err(ConflictKind::StructureChanged));
134 }
135 }
136 Ok(Ok(prepared))
137 }
138
139 pub(crate) fn review(&self, source: &[u8], space: ExGuid, edits: &[PageEdit]) -> Result<Self> {
140 let identities_match = edits.len() == self.edits.len()
141 && edits.iter().all(|edit| {
142 self.edits
143 .iter()
144 .find(|original| original.space() == edit.space())
145 .is_some_and(|original| {
146 original
147 .reposition(edit.position(), edit.level())
148 .is_ok_and(|revised| revised == *edit)
149 })
150 });
151 if !identities_match {
152 return Err(io::Error::new(
153 io::ErrorKind::InvalidInput,
154 "Retain the original page and series identities when reviewing a page batch",
155 )
156 .into());
157 }
158 let (root, reviewed, _) = Self::capture(source, edits)?;
159 if root != space {
160 return Err(io::Error::new(
161 io::ErrorKind::InvalidInput,
162 "Review the original section for page edits",
163 )
164 .into());
165 }
166 Ok(reviewed)
167 }
168}
crates/notebook/src/paragraph.rs created+200
...@@ -0,0 +1,200 @@
1use super::*;
2use onestore::{ParagraphJoin, ParagraphSplit};
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
7#[serde(deny_unknown_fields)]
8struct 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)]
16pub 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)]
24pub struct JoinEdit {
25 pub intent: ParagraphJoin,
26 observed: [Observed; 2],
27}
28
29fn 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
71impl 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
95impl 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
141impl 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 @@
1use std::ops::Range;
2
3const INFINITY: u32 = 1 << 30;
4const MAX_CELLS: usize = 4_000_000;
5
6struct Matrix {
7 band: usize,
8 values: Vec<u32>,
9}
10
11impl Matrix {
12 fn get(&self, row: usize, column: usize) -> u32 {
13 let Some(column) = column
14 .checked_add(self.band)
15 .and_then(|at| at.checked_sub(row))
16 else {
17 return INFINITY;
18 };
19 let width = self.band * 2 + 1;
20 if column >= width {
21 return INFINITY;
22 }
23 self.values
24 .get(row * width + column)
25 .copied()
26 .unwrap_or(INFINITY)
27 }
28
29 fn build(before: &[char], after: &[char], band: usize) -> Self {
30 let width = band * 2 + 1;
31 let mut matrix = Self {
32 band,
33 values: vec![INFINITY; (before.len() + 1) * width],
34 };
35 for row in 0..=before.len() {
36 for column in row.saturating_sub(band)..=after.len().min(row + band) {
37 let mut cost = if row == 0 && column == 0 { 0 } else { INFINITY };
38 if row > 0 {
39 cost = cost.min(matrix.get(row - 1, column) + 1);
40 }
41 if column > 0 {
42 cost = cost.min(matrix.get(row, column - 1) + 1);
43 }
44 if row > 0 && column > 0 && before[row - 1] == after[column - 1] {
45 cost = cost.min(matrix.get(row - 1, column - 1));
46 }
47 matrix.values[row * width + column + band - row] = cost;
48 }
49 }
50 matrix
51 }
52}
53
54/// Requires the same mapping in every minimum insertion/deletion alignment.
55pub(crate) fn rebase(before: &str, after: &str, range: Range<u32>) -> Option<Range<u32>> {
56 if range.start > range.end {
57 return None;
58 }
59 let mut a: Vec<_> = before.chars().collect();
60 let offsets: Vec<_> = std::iter::once(0)
61 .chain(a.iter().scan(0_u32, |at, character| {
62 *at = at.checked_add(character.len_utf16() as u32)?;
63 Some(*at)
64 }))
65 .collect();
66 let local =
67 offsets.binary_search(&range.start).ok()?..offsets.binary_search(&range.end).ok()?;
68 if before == after {
69 return Some(range);
70 }
71 let mut b: Vec<_> = after.chars().collect();
72 let (n, m) = (a.len(), b.len());
73 let mut band = n.abs_diff(m).max(1);
74 let forward = loop {
75 let width = band.checked_mul(2)?.checked_add(1)?;
76 if (n + 1).checked_mul(width)? > MAX_CELLS {
77 return None;
78 }
79 let matrix = Matrix::build(&a, &b, band);
80 if matrix.get(n, m) <= band as u32 {
81 break matrix;
82 }
83 band *= 2;
84 };
85 let distance = forward.get(n, m);
86 a.reverse();
87 b.reverse();
88 let backward = Matrix::build(&a, &b, band);
89 a.reverse();
90 b.reverse();
91 let position = |index: usize| {
92 let mut matched = None;
93 for column in index.saturating_sub(band)..=m.min(index + band) {
94 let cost = forward.get(index, column);
95 if cost + 1 + backward.get(n - index - 1, m - column) == distance {
96 return None;
97 }
98 if b.get(column) == Some(&a[index])
99 && cost + backward.get(n - index - 1, m - column - 1) == distance
100 {
101 if matched.is_some() {
102 return None;
103 }
104 matched = Some(column);
105 }
106 }
107 matched
108 };
109 let mapped = if local.is_empty() {
110 if local.start > 0 {
111 position(local.start - 1)?;
112 }
113 if local.start < n {
114 position(local.start)?;
115 }
116 let mut boundary = None;
117 for column in local.start.saturating_sub(band)..=m.min(local.start + band) {
118 if forward.get(local.start, column) + backward.get(n - local.start, m - column)
119 == distance
120 {
121 if boundary.is_some() {
122 return None;
123 }
124 boundary = Some(column);
125 }
126 }
127 let at = boundary?;
128 at..at
129 } else {
130 let start = position(local.start)?;
131 for index in local.start + 1..local.end {
132 if position(index)? != start + index - local.start {
133 return None;
134 }
135 }
136 start..start + local.len()
137 };
138 let start = b[..mapped.start]
139 .iter()
140 .map(|character| character.len_utf16())
141 .sum::<usize>();
142 let end = start
143 + b[mapped]
144 .iter()
145 .map(|character| character.len_utf16())
146 .sum::<usize>();
147 Some(u32::try_from(start).ok()?..u32::try_from(end).ok()?)
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn optimal_paths(a: &[char], b: &[char]) -> Vec<Vec<(usize, usize)>> {
155 fn visit(
156 a: &[char],
157 b: &[char],
158 path: &mut Vec<(usize, usize)>,
159 cost: usize,
160 best: &mut usize,
161 found: &mut Vec<Vec<(usize, usize)>>,
162 ) {
163 if cost > *best {
164 return;
165 }
166 let (i, j) = *path.last().unwrap();
167 if (i, j) == (a.len(), b.len()) {
168 if cost < *best {
169 found.clear();
170 *best = cost;
171 }
172 found.push(path.clone());
173 return;
174 }
175 for (next_i, next_j, charge) in [(i + 1, j + 1, 0), (i + 1, j, 1), (i, j + 1, 1)] {
176 if next_i > a.len() || next_j > b.len() || (charge == 0 && a[i] != b[j]) {
177 continue;
178 }
179 path.push((next_i, next_j));
180 visit(a, b, path, cost + charge, best, found);
181 path.pop();
182 }
183 }
184 let mut found = Vec::new();
185 let mut best = usize::MAX;
186 visit(a, b, &mut vec![(0, 0)], 0, &mut best, &mut found);
187 found
188 }
189
190 #[test]
191 fn bounded_alignment_agrees_with_exhaustive_paths_for_every_small_unicode_edit() {
192 let mut words = vec![String::new()];
193 for length in 1..=4 {
194 for bits in 0..1 << length {
195 words.push(
196 (0..length)
197 .map(|bit| if bits & (1 << bit) == 0 { 'a' } else { '🦀' })
198 .collect(),
199 );
200 }
201 }
202 let mut cases = 0;
203 for before in &words {
204 let a: Vec<_> = before.chars().collect();
205 for after in &words {
206 let b: Vec<_> = after.chars().collect();
207 let paths = optimal_paths(&a, &b);
208 for start in 0..=a.len() {
209 for end in start..=a.len() {
210 let mut expected = None;
211 let mut valid = true;
212 for path in &paths {
213 let mapping: Vec<_> = (0..a.len())
214 .map(|i| {
215 path.windows(2).find_map(|edge| {
216 (edge[0].0 == i && edge[1] == (i + 1, edge[0].1 + 1))
217 .then_some(edge[0].1)
218 })
219 })
220 .collect();
221 let candidate = if start == end {
222 let vertices: Vec<_> = path
223 .iter()
224 .filter(|(i, _)| *i == start)
225 .map(|(_, j)| *j)
226 .collect();
227 if (start > 0 && mapping[start - 1].is_none())
228 || (start < a.len() && mapping[start].is_none())
229 || vertices.len() != 1
230 {
231 None
232 } else {
233 Some(vertices[0]..vertices[0])
234 }
235 } else if let Some(first) = mapping[start] {
236 (start..end)
237 .all(|i| mapping[i] == Some(first + i - start))
238 .then_some(first..first + end - start)
239 } else {
240 None
241 };
242 let Some(candidate) = candidate else {
243 valid = false;
244 break;
245 };
246 if expected.as_ref().is_some_and(|old| *old != candidate) {
247 valid = false;
248 break;
249 }
250 expected = Some(candidate);
251 }
252 let expected = if valid {
253 expected.map(|range| {
254 b[..range.start].iter().map(|c| c.len_utf16() as u32).sum()
255 ..b[..range.end].iter().map(|c| c.len_utf16() as u32).sum()
256 })
257 } else {
258 None
259 };
260 let range = a[..start].iter().map(|c| c.len_utf16() as u32).sum()
261 ..a[..end].iter().map(|c| c.len_utf16() as u32).sum();
262 assert_eq!(
263 rebase(before, after, range),
264 expected,
265 "{before:?} -> {after:?}, characters {start}..{end}"
266 );
267 cases += 1;
268 }
269 }
270 }
271 }
272 assert_eq!(cases, 10881);
273 }
274
275 #[test]
276 fn maps_disjoint_unicode_edits_and_rejects_ambiguous_or_overlapping_changes() {
277 assert_eq!(rebase("ab🦀cd", "Xab🦀cYd", 2..4), Some(3..5));
278 assert_eq!(rebase("abc", "XabcY", 1..2), Some(2..3));
279 assert_eq!(rebase("abc", "XabcY", 1..1), Some(2..2));
280 assert_eq!(rebase("abc", "abcX", 3..3), None);
281 assert_eq!(rebase("abc", "ac", 1..2), None);
282 assert_eq!(rebase("abc", "", 1..1), None);
283 assert_eq!(rebase("aaaa", "aaaaa", 1..2), None);
284 assert_eq!(rebase("ab🦀cd", "ab🦀cd", 3..4), None);
285 assert_eq!(rebase("abc", "abc", 4..4), None);
286 }
287
288 #[test]
289 fn long_paragraphs_with_small_remote_changes_use_a_narrow_band() {
290 let text = format!("{}🦀{}", "a".repeat(8192), "b".repeat(8192));
291 assert_eq!(
292 rebase(&text, &format!("X{text}Y"), 8192..8194),
293 Some(8193..8195)
294 );
295 assert_eq!(rebase(&"a".repeat(8192), &"b".repeat(8192), 1..2), None);
296 }
297}
crates/notebook/src/recovery.rs created+190
...@@ -0,0 +1,190 @@
1use super::*;
2use rusqlite::backup::{Backup, StepResult};
3use std::{collections::BTreeMap, fs::File};
4
5const 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)]
9pub 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.
21pub struct Recovery {
22 connection: Connection,
23}
24
25impl 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
85impl 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
140fn 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
171fn 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
182fn 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 @@
1use super::*;
2use rusqlite::{OptionalExtension, Transaction};
3
4const CONFLICTS: &str = "CREATE TABLE conflicts (
5 edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE,
6 kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 6)
7) STRICT;";
8
9const ASSETS: &str = "CREATE TABLE assets (
10 name TEXT PRIMARY KEY NOT NULL,
11 data BLOB NOT NULL,
12 sha256 BLOB NOT NULL CHECK(length(sha256)=32)
13) STRICT;";
14
15pub(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
37pub(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
137fn 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 @@
1use super::*;
2use smb2::msg::query_directory::{
3 FileInformationClass, QueryDirectoryFlags, QueryDirectoryRequest, QueryDirectoryResponse,
4};
5use std::collections::BTreeMap;
6
7/// Observed directory metadata, not a stable notebook identity or a file snapshot.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub 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
16impl 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
104fn 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)]
148mod 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
3use onestore::{CommitError, CommitIo, CommitState, ExGuid};
4use 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};
24use std::{io, ops::Range, sync::Mutex, time::Duration};
25use tokio::runtime::{Handle, Runtime};
26
27mod directory;
28mod remote;
29pub use directory::DirectoryEntry;
30pub use remote::SmbRemote;
31
32#[derive(Default)]
33pub 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.
42pub struct Client {
43 connection: Mutex<Option<Connection>>,
44 tree: Tree,
45 timeout: Duration,
46 runtime: Mutex<Option<Runtime>>,
47}
48
49impl 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
323impl Drop for Client {
324 fn drop(&mut self) {
325 self.retire();
326 }
327}
328
329struct File<'a> {
330 client: &'a Client,
331 id: Option<FileId>,
332}
333impl 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}
422impl Drop for File<'_> {
423 fn drop(&mut self) {
424 let _ = self.release();
425 }
426}
427fn 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
441impl 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)]
518mod tests;
crates/notebook/src/smb/remote.rs created+36
...@@ -0,0 +1,36 @@
1use super::Client;
2use crate::Remote;
3use onestore::{CommitError, PreparedEdit};
4use 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.
8pub struct SmbRemote {
9 client: Client,
10 path: String,
11 limit: usize,
12}
13
14impl 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
24impl 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 @@
1use super::*;
2use onestore::{
3 RevisionIndex, Store,
4 document::{Document, Kind},
5};
6use std::{
7 fs,
8 time::{Instant, SystemTime, UNIX_EPOCH},
9};
10
11mod faults;
12
13#[test]
14#[ignore = "requires an owned Samba directory and ONESTORE_SMB_DIRECTORY_ORACLE from its local filesystem"]
15fn 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"]
82fn 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
94fn 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}
103fn 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}
131fn 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"]
153fn 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"]
349fn 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"]
386fn 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"]
435fn 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]
470fn 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 @@
1use super::*;
2use serde_json::{Value, json};
3use std::{collections::BTreeMap, path::Path, process::Child};
4
5#[derive(Clone)]
6struct Snapshot {
7 content: BTreeMap<ExGuid, String>,
8 modified: BTreeMap<(ExGuid, ExGuid), u32>,
9}
10
11fn 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
68fn stamp() -> u32 {
69 (SystemTime::now()
70 .duration_since(UNIX_EPOCH)
71 .unwrap()
72 .as_secs()
73 - 315532800)
74 .try_into()
75 .unwrap()
76}
77
78fn 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]
113fn 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
137struct Proxy(Child);
138impl Drop for Proxy {
139 fn drop(&mut self) {
140 let _ = self.0.kill();
141 let _ = self.0.wait();
142 }
143}
144
145fn 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
154fn 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
174fn 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"]
204fn 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"]
267fn 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 @@
1use super::*;
2use onestore::{CommitError, CommitState};
3use rusqlite::OptionalExtension;
4use 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.
11pub 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)]
19pub 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)]
30pub 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
43impl 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
604pub(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
656fn 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 @@
1use super::*;
2use onestore::{FileDataReference, IdStream, ObjectData, PropertySets, ResolvedRevision, Value};
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::{BTreeMap, BTreeSet};
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8#[serde(untagged, deny_unknown_fields)]
9enum Content {
10 Legacy([u8; 32]),
11 Semantic { sha256: [u8; 32] },
12}
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16struct 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)]
26pub struct TreeEdit {
27 pub intent: onestore::TreeEdit,
28 observed: Observed,
29 created: BTreeSet<ExGuid>,
30}
31
32fn 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
162fn 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
224fn 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
235impl 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
248impl 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)]
331mod 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 @@
1use super::*;
2use 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
14pub(super) struct Signal {
15 stopped: AtomicBool,
16 sender: SyncSender<()>,
17}
18
19impl 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.
28pub struct SyncWorker {
29 signal: Arc<Signal>,
30 thread: Option<JoinHandle<Result<()>>>,
31}
32
33impl 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
54impl Drop for SyncWorker {
55 fn drop(&mut self) {
56 self.signal.stopped.store(true, Ordering::Release);
57 self.signal.wake();
58 }
59}
60
61impl 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 @@
1use notebook::{EditStatus, Error, Operation, Recovery, Replica};
2use std::{
3 fs, io,
4 path::Path,
5 sync::{Barrier, mpsc},
6 time::Duration,
7};
8
9const FIXTURE: &str = "../../corpus/native-external-assets/notebook";
10const LEGACY: &str = "../../corpus/offline-v4/live.sqlite";
11
12fn 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
19fn 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
31struct Payload(Option<io::Result<Vec<u8>>>);
32impl 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]
42fn 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]
106fn 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]
123fn 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]
155fn 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]
172fn 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]
226fn 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]
249fn 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]
288fn 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]
336fn 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"]
410fn 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 @@
1use notebook::{Error, Replica};
2use onestore::{
3 ExGuid, RevisionIndex, Store,
4 document::{Document, Kind},
5};
6use std::{
7 collections::BTreeSet,
8 fs,
9 io::ErrorKind,
10 sync::Barrier,
11 time::{Duration, Instant},
12};
13
14fn 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]
36fn 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]
84fn 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]
115fn 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]
176fn 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]
224fn 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]
321fn 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"]
398fn 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]
572fn 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]
711fn 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]
798fn 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 @@
1use notebook::discover::{Entry, Error, Limits, Local, Source, discover};
2use std::{fs, io, path::Path};
3
4fn limits() -> Limits {
5 Limits {
6 entries: 1000,
7 bytes_per_file: 16 * 1024 * 1024,
8 depth: 16,
9 }
10}
11
12fn 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]
23fn 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]
70fn 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]
88fn 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]
126fn 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]
162fn 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]
204fn 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]
250fn 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 @@
1use notebook::discover::{Error, Local, read_external_asset};
2use std::{fs, io};
3
4#[test]
5fn 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]
36fn 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]
77fn 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"]
115fn 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 @@
1use crate::disk;
2use notebook::{EditStatus, Operation, Remote, Replica};
3use onestore::{
4 CommitError, ExGuid, Insertion, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store,
5 document::{Document, Kind},
6};
7use std::{io, sync::LazyLock};
8
9#[path = "../../../onestore/tests/support/current.rs"]
10mod current;
11
12static 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
24fn 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
44struct Session<'a> {
45 disk: &'a mut disk::Disk,
46 operation: Option<&'a Operation>,
47}
48
49impl 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
107pub 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 @@
1use crate::disk;
2use notebook::{EditStatus, Operation, Remote, Replica};
3use onestore::{
4 CommitError, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, TreeEdit,
5 document::{Document, Kind},
6};
7use std::{io, sync::LazyLock};
8
9static 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
34fn 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
68struct Session<'a> {
69 disk: &'a mut disk::Disk,
70 operation: Option<&'a Operation>,
71}
72
73impl 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
138pub 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 @@
1use notebook::{ConflictKind, EditStatus, Error, Remote, Replica};
2use onestore::{
3 CommitError, CommitIo, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store,
4 document::{Document, Kind},
5};
6use std::io;
7
8#[path = "../../onestore/tests/support/disk.rs"]
9mod disk;
10#[path = "sync/outline.rs"]
11mod outline;
12#[path = "sync/page.rs"]
13mod page;
14#[path = "support/page_schedule.rs"]
15mod page_schedule;
16#[path = "sync/pages.rs"]
17mod pages;
18#[path = "sync/tree.rs"]
19mod tree;
20#[path = "support/tree_schedule.rs"]
21mod tree_schedule;
22
23mod 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)]
967enum Fault {
968 #[default]
969 None,
970 Before,
971 UnknownBefore,
972 UnknownAfter,
973 Committed,
974 PanicBefore,
975 PanicAfter,
976 Confirm,
977 ConfirmCommitted,
978}
979
980struct Server {
981 visible: Vec<u8>,
982 durable: Vec<u8>,
983 fault: Fault,
984 publications: usize,
985 confirmations: usize,
986}
987
988impl 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
1000fn failure(state: CommitState) -> CommitError {
1001 CommitError {
1002 state,
1003 error: io::Error::from(io::ErrorKind::ConnectionAborted),
1004 }
1005}
1006
1007impl 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
1029impl 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
1069fn 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]
1090fn 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]
1232fn 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]
1275fn 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]
1308fn 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]
1338fn 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]
1380fn 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]
1410fn 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]
1437fn 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]
1474fn 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]
1517fn 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]
1638fn 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]
1660fn 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]
1708fn 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]
1768fn 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]
1834fn 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]
1869fn 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]
1952fn 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
2023mod 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]
2699fn 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]
2777fn 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]
2863fn 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]
2939fn 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]
2977fn 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]
3015fn 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]
3048fn 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]
3151fn 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]
3202fn 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]
3242fn 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]
3299fn 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]
3319fn 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]
3533fn 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]
3592fn 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]
3713fn 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]
3877fn 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 @@
1use super::*;
2use onestore::OutlineEdit as Change;
3
4pub(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
27pub(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]
38fn 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]
112fn 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]
187fn 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]
267fn 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]
297fn 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]
410fn 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]
468fn 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]
663fn 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 @@
1use super::*;
2use notebook::{Operation, Recovery};
3use onestore::{Insertion, PageCreation};
4use std::collections::BTreeMap;
5
6#[test]
7fn 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]
27fn 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]
101fn 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]
173fn 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]
228fn 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]
289fn 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 @@
1use super::*;
2use notebook::{Operation, Recovery};
3use onestore::{PageEdit, PagePosition};
4use std::collections::BTreeMap;
5
6const SOURCE: &[u8] =
7 include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
8
9fn 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
29fn 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]
46fn 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]
96fn 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]
121fn 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]
178fn 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]
209fn 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]
260fn 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]
316fn 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]
346fn 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]
370fn 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]
414fn 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]
542fn 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 @@
1use super::*;
2use onestore::{Insertion, TreeEdit};
3
4#[test]
5fn 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
20fn 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
37fn 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]
49fn 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]
104fn 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]
148fn 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]
209fn 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]
300fn 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]
382fn 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]
443fn 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]
518fn 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]
545fn 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]
663fn 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]
2name = "onestore-diagnostic"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[dependencies]
8onestore = { path = "../onestore" }
9onestore-notebook = { path = "../onestore-notebook" }
10serde = { version = "1.0.229", features = ["derive"] }
11serde_json = "1.0.151"
crates/onestore-diagnostic/src/main.rs deleted-126
...@@ -1,126 +0,0 @@
1use onestore::{ExGuid, Insertion, PreparedEdit, TextAttribute};
2use serde::Deserialize;
3use serde_json::{Value, json};
4use std::{
5 env, fs,
6 io::{self, Write},
7};
8
9#[derive(Deserialize)]
10#[serde(deny_unknown_fields)]
11struct Edit {
12 space: ExGuid,
13 object: ExGuid,
14 action: Action,
15}
16
17#[derive(Deserialize)]
18#[serde(tag = "type", deny_unknown_fields)]
19enum 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
43fn 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
116fn 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]
2name = "onestore-notebook"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[features]
8smb = ["dep:onestore-smb"]
9protected = ["onestore/protected", "dep:zeroize"]
10
11[dependencies]
12onestore = { path = "../onestore" }
13onestore-smb = { path = "../onestore-smb", optional = true }
14serde = { version = "1", features = ["derive"] }
15thiserror = "2"
16zeroize = { version = "1.9.0", optional = true }
17
18[dev-dependencies]
19serde_json = "1"
20tempfile = "3"
crates/onestore-notebook/README.md deleted-25
...@@ -1,25 +0,0 @@
1# onestore-notebook
2
3Read-only notebook discovery over a caller-supplied root. This experimental
4sibling crate keeps directory traversal out of the single-file storage parser.
5
6Discovery returns ordered sections and nested groups with file identities and
7share-relative paths. Section display-name overrides remain distinct from file
8names. TOC references whose identities are absent from the directory remain
9inspectable; a cached filename never substitutes for an identity match.
10Reserved `_onefiles` directories are excluded from section-group traversal.
11Encrypted sections and valid storage with an unreadable document graph retain
12their identity as `Locked` or `Unreadable`, without being presented as empty pages.
13Malformed storage, failed reads and ambiguous identities reject the discovery.
14
15Each file read must be a consistent, bounded snapshot. The result is an
16observation across multiple files, not an atomic notebook transaction or
17authorization to publish an edit. Refresh rejects observed topology changes and
18duplicate physical files with the same logical identity. Retain the last accepted
19catalog 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
22selected section's sibling `_onefiles` folder and returns exact bounded bytes.
23Missing files, permissions and size failures retain their I/O error kinds; an
24empty payload is a successful empty buffer. Embedded payloads remain available
25directly from the core document model.
crates/onestore-notebook/examples/discover.rs deleted-40
...@@ -1,40 +0,0 @@
1use onestore_notebook::{Limits, Local, discover};
2
3fn 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 @@
1use onestore::{
2 FileDataReference, RevisionIndex, Store,
3 document::{Document, Kind},
4};
5use std::{
6 borrow::Cow,
7 collections::BTreeSet,
8 env, fs,
9 io::{self, BufWriter, Write},
10 path::{Path, PathBuf},
11};
12
13fn 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
23fn 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.
2use onestore::{FileDataReference, RevisionIndex, Store};
3use std::{fs, path::PathBuf};
4fn 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
4use onestore::{
5 FileType, RevisionIndex, Store,
6 document::{Document, Kind},
7};
8use serde::Serialize;
9use std::{
10 collections::{BTreeMap, BTreeSet},
11 io,
12};
13
14mod source;
15pub use source::Local;
16#[cfg(feature = "smb")]
17pub use source::Smb;
18
19#[derive(Debug, Serialize)]
20pub 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)]
28pub 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)]
38pub 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)]
46pub 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)]
54pub 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)]
62pub enum EntryKind {
63 File,
64 Directory,
65 Other,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
69pub 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.
75pub 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.
88pub 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
122pub struct Limits {
123 pub entries: usize,
124 pub bytes_per_file: usize,
125 pub depth: usize,
126}
127
128#[derive(Debug, thiserror::Error)]
129pub 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.
158pub 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
164fn 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
403fn component(name: &str) -> bool {
404 !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
405}
406
407fn 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 @@
1use super::{Entry, EntryKind, Source, component};
2use std::{
3 io,
4 path::{Path, PathBuf},
5};
6
7/// A canonical local root; symbolic-link entries are not traversed.
8pub struct Local {
9 root: PathBuf,
10}
11
12impl 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
33impl 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.
67pub struct Smb<'a> {
68 client: &'a onestore_smb::Client,
69 root: String,
70}
71
72#[cfg(feature = "smb")]
73impl<'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")]
95impl 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 @@
1use onestore_notebook::{Error, Local, read_external_asset};
2use std::{fs, io};
3
4#[test]
5fn 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]
36fn 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]
77fn 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"]
115fn 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 @@
1use onestore_notebook::{Entry, Error, Limits, Local, Source, discover};
2use std::{fs, io, path::Path};
3
4fn limits() -> Limits {
5 Limits {
6 entries: 1000,
7 bytes_per_file: 16 * 1024 * 1024,
8 depth: 16,
9 }
10}
11
12fn 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]
23fn 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]
70fn 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]
88fn 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]
126fn 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]
162fn 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]
204fn 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]
250fn 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]
2name = "onestore-offline"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[features]
8smb = ["dep:onestore-smb", "onestore-notebook/smb"]
9
10[dependencies]
11onestore = { path = "../onestore" }
12onestore-notebook = { path = "../onestore-notebook" }
13onestore-smb = { path = "../onestore-smb", optional = true }
14rusqlite = { version = "=0.40.2", features = ["bundled", "backup"] }
15thiserror = "2"
16serde = { version = "1", features = ["derive"] }
17serde_json = "1"
18tempfile = "3"
19sha2 = "0.11"
20
21[[example]]
22name = "smb_offline_client"
23required-features = ["smb"]
crates/onestore-offline/README.md deleted-323
...@@ -1,323 +0,0 @@
1# onestore-offline
2
3Durable local editing for a OneNote file, in an optional sibling crate. The current
4foundation stores a complete working image and typed editing intents in a local
5SQLite database. `sync_once` provides a reconciliation step and `start_sync` owns
6automatic polling and reconnects. Local success does not
7acknowledge publication to a shared notebook.
8
9```no_run
10use onestore::ExGuid;
11use 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.
15let cache = Replica::create(path, source)?;
16let snapshot = cache.snapshot()?;
17let local_id = cache.edit_text(&snapshot, space, text, 0..0, "Offline edit ")?;
18drop(cache);
19
20let reopened = Replica::open(path)?;
21let current = reopened.snapshot()?;
22let pending = reopened.pending()?;
23assert_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
29intent identities. `Insertion::with_formatting` queues text and
30its styles as one intent and one publication. Keep that value across retries; its `text_object()` identifies
31the 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
38outline can precede its paragraphs and their later edits. Missing anchors or
39existing insertion identities preserve a conflict and the complete local image.
40
41`create_page` accepts the core `PageCreation` intent and queues its page space
42and section entry as one publication. Subsequent outlines and title edits use
43the intent's stable identities immediately after local acknowledgement.
44Independent page additions rebase against the current section order; duplicate
45titles remain distinct. An unavailable or no-longer-leading insertion anchor
46produces `StructureChanged`. `rebase_page_creation_conflict(id, local, remote,
47before)` reviews a replacement anchor against both cache images while retaining
48the new page and dependent object identities. Existing page identities require
49reconciliation; a matching page alone does not establish a receipt.
50
51`pages(snapshot, edits)` queues a slice of core `PageEdit` intents as one atomic
52publication. `PagePosition::Keep` preserves remote movement during indentation-only
53edits. Every requested level remains explicit: a competing remote level produces
54`StructureChanged` unless it already matches the requested level. Moves compare
55the selected page's position relative to surviving observed pages; an unchanged
56or already-satisfied position can proceed, and new remote pages remain present.
57Indistinguishable competing moves retain a conflict and the complete local image.
58
59`rebase_pages_conflict(id, local, remote, edits)` reviews the batch against both
60cache images. Use `PageEdit::reposition(position, level)` on the retained intents
61to revise anchors or indentation; replacing page or allocated series identities
62is rejected. Dependent edits remain queued. Schema 11 adds the durable batch;
63schema 10 migration preserves existing multi-space publication evidence unchanged.
64Attempt evidence includes every changed space plus the receipt space when that
65space is unchanged. An existing section revision alone cannot confirm a page edit.
66The [offline page corpus](../../corpus/page-lifecycle/offline-edits/README.md)
67contains native comparisons, reproduction commands and stateful sanitizer seeds.
68
69`split` and `join` accept the core `ParagraphSplit` and `ParagraphJoin` intents.
70Splits retain allocated identities when the original UTF-16 boundary rebases;
71dependent edits can address `ParagraphSplit::text_object()` immediately after
72local acknowledgement. Both operations retain observed parent paths, children,
73indentation, paragraph/list state and tags. Changed structure produces
74`StructureChanged`. Remote text changes must leave unambiguous boundary mappings;
75current remote character styles are preserved. Reconciliation does not silently
76discard 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
79allocated identities. `rebase_join_conflict(id, local, remote)` reviews the original
80join against current images. It rejects a change in which text identity survives,
81because dependent edits still address that identity. As with other conflict
82reviews, stale images, pending operations and uncertain attempts cannot be rebased.
83An uncertain structural operation requires its original attempted revision for
84confirmation; 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
88placements for the oldest insertion conflict. They preserve creation time, text,
89author and object identities, keeping later edits attached to their original targets.
90The images must still match the cache; the new placement must be valid in the remote
91image. Paragraph intents cannot become outlines or vice versa. Pending edits and
92uncertain publication attempts cannot be repositioned through conflict review.
93
94```no_run
95use onestore::{ExGuid, Insertion, TextAttribute};
96use onestore_offline::{EditStatus, Replica};
97# fn add_outline(cache: &Replica, space: ExGuid, page: ExGuid)
98# -> Result<(), Box<dyn std::error::Error>> {
99let outline = Insertion::outline(page, 144.0, 216.0, "Offline outline", "Author")?
100 .with_formatting(0..7, &[TextAttribute::Bold(true)])?;
101let id = cache.insert(&cache.snapshot()?, space, &outline)?;
102if 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
114retains the observed text and selected attribute values. Remote text changes must
115leave an unambiguous range; independent remote attributes merge, while competing
116values preserve `FormattingChanged`. A remote value that already matches the
117requested value is accepted. Enabling superscript or subscript also checks the
118opposite attribute that the operation clears. If the remote image already satisfies
119the whole operation, guarded confirmation still precedes a durable receipt.
120
121`outline` accepts a target object and the core `onestore::OutlineEdit`: outline
122position/width or a paragraph's saved collapse default. It retains the target's
123ancestry and the properties the operation changes. Independent content, formatting
124and layout properties merge; competing selected values produce `LayoutChanged`,
125and changed ancestry produces `StructureChanged`. Width changes also check the
126reserved wrapping width that they clear. Missing targets preserve the local branch.
127`rebase_layout_conflict(id, local, remote)` explicitly reviews the original change
128against both current images, preserving dependent intents. An uncertain layout
129attempt requires its original revision; converged values cannot establish its receipt.
130
131`tree` accepts the core `onestore::TreeEdit`. Moves preserve independent remote
132text, formatting and descendants. A competing ancestor, indentation or sibling
133crossing produces `StructureChanged`; unrelated sibling insertions/deletions can
134merge. An anchor must remain a direct child of the requested destination. An
135already satisfied move still requires guarded confirmation before acknowledgement.
136Deletion compares the selected raw property graph, including referenced styles,
137tags, unknown fields and internal attachments. Changed content produces
138`ContentChanged`; property order, CompactID numbering and modification timestamps
139do not affect that comparison. Immutable records compare by content, and empty
140child lists compare equally to absent child lists. Mutable object identities
141remain significant. Missing targets retain `TargetUnavailable`.
142`rebase_tree_conflict(id, local, remote)` reviews the original move/deletion against
143both current images. An emptied cell's replacement paragraph/text identities must
144remain the same before replay or review; later queued edits keep their targets.
145Uncertain tree attempts require their original revision for confirmation, even
146when an independent move or deletion has the same visible effect.
147
148Recognized earlier caches migrate transactionally to version ten. Publication
149attempts retain every changed space's revision; legacy attempts retain their
150single-space evidence. Earlier recovery archives remain readable without migration.
151Version-eight
152deletion observations retain their original identity-sensitive preconditions;
153explicit conflict review upgrades them to the immutable-content comparison.
154The migration retains images, local IDs, publication attempts, conflicts,
155receipts and the autoincrement sequence; it does not reuse acknowledged IDs when
156the pending queue is empty.
157
158Share one `Replica` between application threads. Each edit compares its supplied
159snapshot under the cache transaction; stale snapshots return `Io(ResourceBusy)`.
160The intent and its resulting image commit together. No-op edits return `None`.
161Keep the cache on a local filesystem: the connection holds exclusive ownership
162between transactions, and a second open fails busy. No network wait occurs in a
163local edit. After a database error, reopen and inspect the durable state before
164retrying.
165
166`sync_once(&mut remote)` processes the oldest pending edit through a `Remote`
167implementation, returning its ID and `EditStatus`. A durable `Published` receipt
168survives reopening. Publication attempts are recorded before network I/O; a retained
169attempt requires every recorded revision to remain present, followed by comparison,
170flushing and refreshed header version
171metadata before acknowledgement. If a formatting attempt's revision is missing,
172the complete requested effect can instead be confirmed on a uniquely aligned
173range; its receipt identifies that confirmed current revision. Otherwise the
174missing attempt remains `AwaitingConfirmation`. Neither path replays an uncertain
175publication. Overlapping or ambiguous edits retain `Conflict` status, their complete
176local image and the last observed remote image returned by `remote_snapshot`.
177Transport errors return `Error::Remote` or `Error::RemoteIo`; inspect `status(id)`
178after the error to distinguish a retained attempt from a pending edit or receipt.
179The error return does not roll back a locally acknowledged intent.
180
181Rebasing accepts only character mappings shared by every minimum insertion/deletion
182alignment. UTF-16 ranges must preserve Unicode scalar boundaries. A bounded
183alignment search also leaves a conflict when it cannot establish a unique mapping.
184The current operation processes one queue head; while edits remain, `snapshot`
185preserves the complete local working image. An empty queue can refresh from the
186remote image. Synchronization holds a separate owner lock, so local edits can
187continue during network waits; competing synchronization calls return `WouldBlock`.
188
189`rebase_conflict(id, local, remote, range)` lets a caller explicitly place the
190oldest conflicting text or formatting intent at a reviewed UTF-16 range in the remote image.
191The supplied images must still match `snapshot()` and `remote_snapshot()`.
192It preserves the original replacement or requested attributes, intent ID, complete local working image
193and every later intent; the selected range and remote paragraph become the
194intent's new comparison base in one local transaction. Formatting also captures
195the reviewed attribute values as its new precondition. This operation performs
196no network I/O, clears the conflict to `Pending`, and wakes the worker.
197Publication still reads the latest remote image and uses exact guarded comparison;
198another overlapping remote edit can produce a new conflict. An uncertain attempt
199cannot be rebased, and selecting text that already equals the replacement does
200not create a publication acknowledgement.
201
202With 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
204object space, which survives the tested native compaction that replaces the file ID.
205
206An `Arc<Replica>` can own one background worker. Supply a connection factory, poll
207interval and observer; successful publications drain immediately, durable local
208edits wake the worker, and `wake()` requests an immediate reachability retry.
209Transport failures discard the old connection and retry through the factory;
210Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy`
211reuse the connection. Contended `NotCommitted` operations use randomized backoff,
212capped at one second, to separate competing retry cycles. Cancellation interrupts this delay; local wake notifications
213remain coalesced until its end.
214`RemoteIo` distinguishes connection/read failures from
215local `Io` errors. Cache/document errors stop the worker. Observers receive every
216attempt's result on the worker thread, including unchanged conflict/uncertain
217statuses; 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>> {
223use onestore_offline::SmbRemote;
224use onestore_smb::{Client, Credentials};
225use std::time::Duration;
226
227let 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.
241worker.stop()?;
242# Ok(())
243# }
244```
245
246`stop()` cancels future steps and joins the worker, returning a fatal cache error
247or worker panic. Dropping it requests cancellation without waiting. An in-flight
248step completes before releasing ownership; a replacement worker cannot start
249while the old one still owns the replica. Remote operations and callbacks must
250have bounded execution times if shutdown latency matters. A dropped worker can
251briefly retain the cache; use `stop()` before requiring an immediate reopen.
252Cancellation and application restart retain pending edits and publication attempts.
253Credentials belong to the factory, not the cache database.
254
255Creation refuses existing paths. Opening recognizes the application identity and
256schema version, validates database integrity and both notebook images, and rejects
257unsupported journal modes without converting them. Failed initialization preserves
258the file for inspection. SQLite uses DELETE journaling, EXTRA synchronization and
259fullfsync; each required setting is queried back.
260
261The cache and its pending intents contain notebook content. The core `onestore`
262crate stays independent of SQLite and network runtimes. Device and simulator
263examples compile and link for iOS; recorded process-interruption tests do not
264establish physical power-loss durability. Evidence is tracked in [Milestone 9](../../evidence/MILESTONE9.md).
265
266The SMB-enabled `smb_offline_client` example is an owned-lab workload for
267`tools/native_collaboration.py --offline --embedded-smb`. It separates local
268acknowledgements, remote publication attempts, persisted receipts and cache reopen
269checks. Its append-specific conflict review policy lives in the test client;
270the 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
275queue, uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one
276SQLite snapshot. It refuses existing destinations and leaves the live queue
277unchanged. Export to a local directory from a background thread: copying holds
278the cache mutex while capturing the database. Failure after the final rename can
279leave a complete archive at the requested path; it never acknowledges a remote
280edit. Archives contain notebook content and use a separate database identity, so
281`Replica::open` rejects them as writable caches.
282
283```no_run
284use onestore_offline::{Recovery, Replica};
285# fn example(cache: &Replica) -> Result<(), Box<dyn std::error::Error>> {
286cache.export_recovery("review.sqlite")?;
287let review = Recovery::open("review.sqlite")?;
288let counts = review.summary()?;
289let local = review.snapshot()?;
290let remote = review.remote_snapshot()?;
291let pending = review.pending()?;
292let 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
300counts and byte sizes without notebook text, paths, authors or credentials.
301Opening an archive validates its schema and images without migration. A recovery
302archive 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
307payload through `onestore-notebook::Source` and durably caches its exact bytes.
308The 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
310SMB sources use the same API. Downloads release the cache mutex during network
311I/O and recheck the reference before committing; edits can continue meanwhile.
312
313`cached_asset(filename, limit)` reads previously downloaded bytes without network
314access. `None` means never downloaded; `Some(Vec::new())` is a downloaded empty
315payload. Each read checks the stored SHA-256 and enforces the byte limit before
316loading the payload. Cache contents describe the prior download, not current
317server reachability or presence. A failed fetch returns its error without
318silently substituting cached bytes. Different bytes for an already cached file
319identity return `AssetChanged` and preserve the previous download.
320
321Downloads do not change pending edits, publication attempts or receipts. Recovery
322archives include cached media and expose the same bounded `cached_asset` lookup.
323Schema-4 archives remain readable without migration and contain no media cache.
crates/onestore-offline/examples/cache_probe.rs deleted-238
...@@ -1,238 +0,0 @@
1use onestore::{
2 ExGuid, Insertion, RevisionIndex, Store, TextAttribute,
3 document::{Document, Kind},
4};
5use onestore_offline::Replica;
6use std::{
7 io::{self, BufRead, Write},
8 path::Path,
9};
10
11fn 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
33fn payload(operation: u64, size: usize) -> String {
34 format!("{operation}:🦀{}", "x".repeat(size))
35}
36
37fn 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 @@
1mod support {
2 pub mod view;
3}
4use support::view::view;
5
6use onestore::{CommitError, CommitIo, PreparedEdit};
7use onestore_offline::{EditStatus, Remote, Replica};
8use serde_json::json;
9use std::{
10 env,
11 fs::{self, File, OpenOptions},
12 io::{self, Write},
13 os::unix::fs::FileExt,
14 path::Path,
15};
16
17fn 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
29struct Disk {
30 file: File,
31 writes: usize,
32 flushes: usize,
33}
34
35impl 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
59impl 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
84fn 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
108fn 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"]
2mod concurrent;
3
4use onestore::{
5 CommitError, CommitState, ExGuid, Insertion, ParagraphJoin, ParagraphSplit, PreparedEdit,
6 RevisionIndex, Store, TextAttribute, TreeEdit, document::Document,
7};
8use onestore_offline::{EditStatus, Error, Remote, Replica, SmbRemote};
9use onestore_smb::{Client, Credentials};
10use serde_json::json;
11use 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
20mod support {
21 pub mod view;
22}
23use concurrent::{DocumentView, document_view};
24use support::view::view;
25
26impl 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
44const 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
57fn now() -> u128 {
58 SystemTime::now()
59 .duration_since(UNIX_EPOCH)
60 .unwrap()
61 .as_micros()
62}
63
64#[derive(Clone)]
65enum Pause {
66 Outage(PathBuf),
67 FormatReply(PathBuf),
68}
69
70struct Traced<R> {
71 remote: R,
72 before: Option<(String, Option<DocumentView>)>,
73 pause: Option<Pause>,
74 documents: bool,
75}
76
77impl<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
261fn 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.
285fn 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
298fn 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
454fn 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]
672fn 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"]
704mod disk;
705
706#[cfg(test)]
707mod 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 @@
1use onestore::{
2 ExGuid, RevisionIndex, Store,
3 document::{Document, Kind},
4};
5
6pub struct View {
7 pub space: ExGuid,
8 pub object: ExGuid,
9 pub revision: ExGuid,
10 pub text: String,
11}
12
13pub 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 @@
1use super::*;
2use onestore::FileDataReference;
3use rusqlite::OptionalExtension;
4use sha2::{Digest, Sha256};
5
6impl 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
76pub(crate) fn key(filename: &str) -> Result<String> {
77 format!("<file>{filename}").parse::<FileDataReference>()?;
78 Ok(filename.to_ascii_lowercase())
79}
80
81fn 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
101pub(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 @@
1use super::*;
2use onestore::TextAttribute;
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5use std::collections::BTreeMap;
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8#[serde(deny_unknown_fields)]
9struct 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)]
17pub 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
25fn 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
61fn 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
104impl 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
120impl 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
4use onestore::{
5 ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store,
6 document::{Document, Kind},
7};
8use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};
9use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration};
10
11mod assets;
12mod formatting;
13mod outline;
14mod pages;
15mod paragraph;
16mod rebase;
17mod recovery;
18mod schema;
19mod tree;
20pub use formatting::FormatEdit;
21pub use outline::OutlineEdit;
22pub use pages::PageEdits;
23pub use paragraph::{JoinEdit, SplitEdit};
24pub use recovery::{Recovery, RecoverySummary};
25pub use tree::TreeEdit;
26mod sync;
27pub use sync::{ConflictKind, EditStatus, Remote};
28mod worker;
29pub use worker::SyncWorker;
30#[cfg(feature = "smb")]
31mod smb;
32#[cfg(feature = "smb")]
33pub use smb::SmbRemote;
34
35#[derive(Debug, thiserror::Error)]
36pub 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
53type Result<T> = std::result::Result<T, Error>;
54
55const APPLICATION_ID: u32 = 0x4f4e454f;
56const 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)]
61pub 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)]
70pub 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)]
84pub 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.
92pub struct Replica {
93 connection: Mutex<Connection>,
94 synchronization: Mutex<()>,
95 worker: Mutex<std::sync::Weak<worker::Signal>>,
96}
97
98impl 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
303fn 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
332fn 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
346fn 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
361fn 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
384fn 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
413fn 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 @@
1use super::*;
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6#[serde(deny_unknown_fields)]
7struct 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)]
15pub struct OutlineEdit {
16 pub object: ExGuid,
17 pub change: onestore::OutlineEdit,
18 observed: Observed,
19}
20
21fn 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
61impl 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
75impl 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 @@
1use super::*;
2use onestore::{PageEdit, PagePosition};
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6type PageOrder = Vec<(ExGuid, u32)>;
7
8/// An atomic page batch with its observed order and indentation.
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct PageEdits {
12 pub edits: Vec<PageEdit>,
13 observed: PageOrder,
14}
15
16fn observe(source: &[u8]) -> Result<Option<(ExGuid, PageOrder)>> {
17 let store = Store::parse(source)?;
18 let index = RevisionIndex::parse(&store)?;
19 let document = Document::parse(&index)?;
20 let mut pages = Vec::new();
21 for (sid, _) in document.pages()? {
22 let view = document.active(sid)?;
23 let Some(metadata) = view.roots.get(&2).and_then(|id| view.nodes.get(id)) else {
24 return Ok(None);
25 };
26 let Kind::Metadata { level, .. } = metadata.kind else {
27 return Ok(None);
28 };
29 pages.push((sid, level.unwrap_or(1)));
30 }
31 Ok(Some((document.root, pages)))
32}
33
34fn positions(pages: &[(ExGuid, u32)]) -> Result<BTreeMap<ExGuid, (usize, u32)>> {
35 let mut positions = BTreeMap::new();
36 for (at, (sid, level)) in pages.iter().enumerate() {
37 if sid.guid == [0; 16]
38 || !(1..=3).contains(level)
39 || positions.insert(*sid, (at, *level)).is_some()
40 {
41 return Err(io::Error::new(
42 io::ErrorKind::InvalidData,
43 "Page observations need unique identities and valid indentation",
44 )
45 .into());
46 }
47 }
48 Ok(positions)
49}
50
51impl Replica {
52 /// Durably queues the complete page batch using the supplied local snapshot.
53 pub fn pages(&self, source: &[u8], edits: &[PageEdit]) -> Result<Option<u64>> {
54 let (space, batch, prepared) = PageEdits::capture(source, edits)?;
55 self.record(source, space, Operation::Pages(batch), &prepared)
56 }
57}
58
59impl PageEdits {
60 fn capture<'a>(
61 source: &'a [u8],
62 edits: &[PageEdit],
63 ) -> Result<(ExGuid, Self, PreparedEdit<'a>)> {
64 let prepared = PreparedEdit::pages(source, edits)?;
65 let (space, observed) = observe(source)?.ok_or_else(|| {
66 io::Error::new(
67 io::ErrorKind::InvalidData,
68 "Prepared page edits have no page metadata",
69 )
70 })?;
71 positions(&observed)?;
72 Ok((
73 space,
74 Self {
75 edits: edits.to_vec(),
76 observed,
77 },
78 prepared,
79 ))
80 }
81
82 pub(crate) fn prepare<'a>(
83 &self,
84 source: &'a [u8],
85 space: ExGuid,
86 ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> {
87 let Some((root, current)) = observe(source)? else {
88 return Ok(Err(ConflictKind::TargetUnavailable));
89 };
90 if root != space {
91 return Ok(Err(ConflictKind::TargetUnavailable));
92 }
93 let before = positions(&self.observed)?;
94 let current = positions(&current)?;
95 for edit in &self.edits {
96 let Some((_, original_level)) = before.get(&edit.space()) else {
97 return Ok(Err(ConflictKind::TargetUnavailable));
98 };
99 let Some((_, current_level)) = current.get(&edit.space()) else {
100 return Ok(Err(ConflictKind::TargetUnavailable));
101 };
102 if current_level != original_level && *current_level != edit.level() {
103 return Ok(Err(ConflictKind::StructureChanged));
104 }
105 }
106 let prepared = match PreparedEdit::pages(source, &self.edits) {
107 Ok(prepared) => prepared,
108 Err(_) => return Ok(Err(ConflictKind::StructureChanged)),
109 };
110 let (_, wanted) = observe(prepared.as_bytes())?.ok_or_else(|| {
111 io::Error::new(
112 io::ErrorKind::InvalidData,
113 "Prepared page edits lost page metadata",
114 )
115 })?;
116 let wanted = positions(&wanted)?;
117 for edit in &self.edits {
118 if edit.position() == PagePosition::Keep {
119 continue;
120 }
121 let sid = edit.space();
122 let peers = before
123 .keys()
124 .filter(|id| **id != sid && current.contains_key(id));
125 let mut unchanged = true;
126 let mut satisfied = true;
127 for id in peers {
128 let observed = current[id].0 < current[&sid].0;
129 unchanged &= observed == (before[id].0 < before[&sid].0);
130 satisfied &= observed == (wanted[id].0 < wanted[&sid].0);
131 }
132 if !unchanged && !satisfied {
133 return Ok(Err(ConflictKind::StructureChanged));
134 }
135 }
136 Ok(Ok(prepared))
137 }
138
139 pub(crate) fn review(&self, source: &[u8], space: ExGuid, edits: &[PageEdit]) -> Result<Self> {
140 let identities_match = edits.len() == self.edits.len()
141 && edits.iter().all(|edit| {
142 self.edits
143 .iter()
144 .find(|original| original.space() == edit.space())
145 .is_some_and(|original| {
146 original
147 .reposition(edit.position(), edit.level())
148 .is_ok_and(|revised| revised == *edit)
149 })
150 });
151 if !identities_match {
152 return Err(io::Error::new(
153 io::ErrorKind::InvalidInput,
154 "Retain the original page and series identities when reviewing a page batch",
155 )
156 .into());
157 }
158 let (root, reviewed, _) = Self::capture(source, edits)?;
159 if root != space {
160 return Err(io::Error::new(
161 io::ErrorKind::InvalidInput,
162 "Review the original section for page edits",
163 )
164 .into());
165 }
166 Ok(reviewed)
167 }
168}
crates/onestore-offline/src/paragraph.rs deleted-200
...@@ -1,200 +0,0 @@
1use super::*;
2use onestore::{ParagraphJoin, ParagraphSplit};
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
7#[serde(deny_unknown_fields)]
8struct 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)]
16pub 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)]
24pub struct JoinEdit {
25 pub intent: ParagraphJoin,
26 observed: [Observed; 2],
27}
28
29fn 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
71impl 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
95impl 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
141impl 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 @@
1use std::ops::Range;
2
3const INFINITY: u32 = 1 << 30;
4const MAX_CELLS: usize = 4_000_000;
5
6struct Matrix {
7 band: usize,
8 values: Vec<u32>,
9}
10
11impl Matrix {
12 fn get(&self, row: usize, column: usize) -> u32 {
13 let Some(column) = column
14 .checked_add(self.band)
15 .and_then(|at| at.checked_sub(row))
16 else {
17 return INFINITY;
18 };
19 let width = self.band * 2 + 1;
20 if column >= width {
21 return INFINITY;
22 }
23 self.values
24 .get(row * width + column)
25 .copied()
26 .unwrap_or(INFINITY)
27 }
28
29 fn build(before: &[char], after: &[char], band: usize) -> Self {
30 let width = band * 2 + 1;
31 let mut matrix = Self {
32 band,
33 values: vec![INFINITY; (before.len() + 1) * width],
34 };
35 for row in 0..=before.len() {
36 for column in row.saturating_sub(band)..=after.len().min(row + band) {
37 let mut cost = if row == 0 && column == 0 { 0 } else { INFINITY };
38 if row > 0 {
39 cost = cost.min(matrix.get(row - 1, column) + 1);
40 }
41 if column > 0 {
42 cost = cost.min(matrix.get(row, column - 1) + 1);
43 }
44 if row > 0 && column > 0 && before[row - 1] == after[column - 1] {
45 cost = cost.min(matrix.get(row - 1, column - 1));
46 }
47 matrix.values[row * width + column + band - row] = cost;
48 }
49 }
50 matrix
51 }
52}
53
54/// Requires the same mapping in every minimum insertion/deletion alignment.
55pub(crate) fn rebase(before: &str, after: &str, range: Range<u32>) -> Option<Range<u32>> {
56 if range.start > range.end {
57 return None;
58 }
59 let mut a: Vec<_> = before.chars().collect();
60 let offsets: Vec<_> = std::iter::once(0)
61 .chain(a.iter().scan(0_u32, |at, character| {
62 *at = at.checked_add(character.len_utf16() as u32)?;
63 Some(*at)
64 }))
65 .collect();
66 let local =
67 offsets.binary_search(&range.start).ok()?..offsets.binary_search(&range.end).ok()?;
68 if before == after {
69 return Some(range);
70 }
71 let mut b: Vec<_> = after.chars().collect();
72 let (n, m) = (a.len(), b.len());
73 let mut band = n.abs_diff(m).max(1);
74 let forward = loop {
75 let width = band.checked_mul(2)?.checked_add(1)?;
76 if (n + 1).checked_mul(width)? > MAX_CELLS {
77 return None;
78 }
79 let matrix = Matrix::build(&a, &b, band);
80 if matrix.get(n, m) <= band as u32 {
81 break matrix;
82 }
83 band *= 2;
84 };
85 let distance = forward.get(n, m);
86 a.reverse();
87 b.reverse();
88 let backward = Matrix::build(&a, &b, band);
89 a.reverse();
90 b.reverse();
91 let position = |index: usize| {
92 let mut matched = None;
93 for column in index.saturating_sub(band)..=m.min(index + band) {
94 let cost = forward.get(index, column);
95 if cost + 1 + backward.get(n - index - 1, m - column) == distance {
96 return None;
97 }
98 if b.get(column) == Some(&a[index])
99 && cost + backward.get(n - index - 1, m - column - 1) == distance
100 {
101 if matched.is_some() {
102 return None;
103 }
104 matched = Some(column);
105 }
106 }
107 matched
108 };
109 let mapped = if local.is_empty() {
110 if local.start > 0 {
111 position(local.start - 1)?;
112 }
113 if local.start < n {
114 position(local.start)?;
115 }
116 let mut boundary = None;
117 for column in local.start.saturating_sub(band)..=m.min(local.start + band) {
118 if forward.get(local.start, column) + backward.get(n - local.start, m - column)
119 == distance
120 {
121 if boundary.is_some() {
122 return None;
123 }
124 boundary = Some(column);
125 }
126 }
127 let at = boundary?;
128 at..at
129 } else {
130 let start = position(local.start)?;
131 for index in local.start + 1..local.end {
132 if position(index)? != start + index - local.start {
133 return None;
134 }
135 }
136 start..start + local.len()
137 };
138 let start = b[..mapped.start]
139 .iter()
140 .map(|character| character.len_utf16())
141 .sum::<usize>();
142 let end = start
143 + b[mapped]
144 .iter()
145 .map(|character| character.len_utf16())
146 .sum::<usize>();
147 Some(u32::try_from(start).ok()?..u32::try_from(end).ok()?)
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn optimal_paths(a: &[char], b: &[char]) -> Vec<Vec<(usize, usize)>> {
155 fn visit(
156 a: &[char],
157 b: &[char],
158 path: &mut Vec<(usize, usize)>,
159 cost: usize,
160 best: &mut usize,
161 found: &mut Vec<Vec<(usize, usize)>>,
162 ) {
163 if cost > *best {
164 return;
165 }
166 let (i, j) = *path.last().unwrap();
167 if (i, j) == (a.len(), b.len()) {
168 if cost < *best {
169 found.clear();
170 *best = cost;
171 }
172 found.push(path.clone());
173 return;
174 }
175 for (next_i, next_j, charge) in [(i + 1, j + 1, 0), (i + 1, j, 1), (i, j + 1, 1)] {
176 if next_i > a.len() || next_j > b.len() || (charge == 0 && a[i] != b[j]) {
177 continue;
178 }
179 path.push((next_i, next_j));
180 visit(a, b, path, cost + charge, best, found);
181 path.pop();
182 }
183 }
184 let mut found = Vec::new();
185 let mut best = usize::MAX;
186 visit(a, b, &mut vec![(0, 0)], 0, &mut best, &mut found);
187 found
188 }
189
190 #[test]
191 fn bounded_alignment_agrees_with_exhaustive_paths_for_every_small_unicode_edit() {
192 let mut words = vec![String::new()];
193 for length in 1..=4 {
194 for bits in 0..1 << length {
195 words.push(
196 (0..length)
197 .map(|bit| if bits & (1 << bit) == 0 { 'a' } else { '🦀' })
198 .collect(),
199 );
200 }
201 }
202 let mut cases = 0;
203 for before in &words {
204 let a: Vec<_> = before.chars().collect();
205 for after in &words {
206 let b: Vec<_> = after.chars().collect();
207 let paths = optimal_paths(&a, &b);
208 for start in 0..=a.len() {
209 for end in start..=a.len() {
210 let mut expected = None;
211 let mut valid = true;
212 for path in &paths {
213 let mapping: Vec<_> = (0..a.len())
214 .map(|i| {
215 path.windows(2).find_map(|edge| {
216 (edge[0].0 == i && edge[1] == (i + 1, edge[0].1 + 1))
217 .then_some(edge[0].1)
218 })
219 })
220 .collect();
221 let candidate = if start == end {
222 let vertices: Vec<_> = path
223 .iter()
224 .filter(|(i, _)| *i == start)
225 .map(|(_, j)| *j)
226 .collect();
227 if (start > 0 && mapping[start - 1].is_none())
228 || (start < a.len() && mapping[start].is_none())
229 || vertices.len() != 1
230 {
231 None
232 } else {
233 Some(vertices[0]..vertices[0])
234 }
235 } else if let Some(first) = mapping[start] {
236 (start..end)
237 .all(|i| mapping[i] == Some(first + i - start))
238 .then_some(first..first + end - start)
239 } else {
240 None
241 };
242 let Some(candidate) = candidate else {
243 valid = false;
244 break;
245 };
246 if expected.as_ref().is_some_and(|old| *old != candidate) {
247 valid = false;
248 break;
249 }
250 expected = Some(candidate);
251 }
252 let expected = if valid {
253 expected.map(|range| {
254 b[..range.start].iter().map(|c| c.len_utf16() as u32).sum()
255 ..b[..range.end].iter().map(|c| c.len_utf16() as u32).sum()
256 })
257 } else {
258 None
259 };
260 let range = a[..start].iter().map(|c| c.len_utf16() as u32).sum()
261 ..a[..end].iter().map(|c| c.len_utf16() as u32).sum();
262 assert_eq!(
263 rebase(before, after, range),
264 expected,
265 "{before:?} -> {after:?}, characters {start}..{end}"
266 );
267 cases += 1;
268 }
269 }
270 }
271 }
272 assert_eq!(cases, 10881);
273 }
274
275 #[test]
276 fn maps_disjoint_unicode_edits_and_rejects_ambiguous_or_overlapping_changes() {
277 assert_eq!(rebase("ab🦀cd", "Xab🦀cYd", 2..4), Some(3..5));
278 assert_eq!(rebase("abc", "XabcY", 1..2), Some(2..3));
279 assert_eq!(rebase("abc", "XabcY", 1..1), Some(2..2));
280 assert_eq!(rebase("abc", "abcX", 3..3), None);
281 assert_eq!(rebase("abc", "ac", 1..2), None);
282 assert_eq!(rebase("abc", "", 1..1), None);
283 assert_eq!(rebase("aaaa", "aaaaa", 1..2), None);
284 assert_eq!(rebase("ab🦀cd", "ab🦀cd", 3..4), None);
285 assert_eq!(rebase("abc", "abc", 4..4), None);
286 }
287
288 #[test]
289 fn long_paragraphs_with_small_remote_changes_use_a_narrow_band() {
290 let text = format!("{}🦀{}", "a".repeat(8192), "b".repeat(8192));
291 assert_eq!(
292 rebase(&text, &format!("X{text}Y"), 8192..8194),
293 Some(8193..8195)
294 );
295 assert_eq!(rebase(&"a".repeat(8192), &"b".repeat(8192), 1..2), None);
296 }
297}
crates/onestore-offline/src/recovery.rs deleted-190
...@@ -1,190 +0,0 @@
1use super::*;
2use rusqlite::backup::{Backup, StepResult};
3use std::{collections::BTreeMap, fs::File};
4
5const 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)]
9pub 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.
21pub struct Recovery {
22 connection: Connection,
23}
24
25impl 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
85impl 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
140fn 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
171fn 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
182fn 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 @@
1use super::*;
2use rusqlite::{OptionalExtension, Transaction};
3
4const CONFLICTS: &str = "CREATE TABLE conflicts (
5 edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE,
6 kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 6)
7) STRICT;";
8
9const ASSETS: &str = "CREATE TABLE assets (
10 name TEXT PRIMARY KEY NOT NULL,
11 data BLOB NOT NULL,
12 sha256 BLOB NOT NULL CHECK(length(sha256)=32)
13) STRICT;";
14
15pub(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
37pub(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
137fn 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 @@
1use crate::Remote;
2use onestore::{CommitError, PreparedEdit};
3use onestore_smb::Client;
4use 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.
8pub struct SmbRemote {
9 client: Client,
10 path: String,
11 limit: usize,
12}
13
14impl 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
24impl 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 @@
1use super::*;
2use onestore::{CommitError, CommitState};
3use rusqlite::OptionalExtension;
4use 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.
11pub 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)]
19pub 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)]
30pub 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
43impl 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
604pub(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
656fn 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 @@
1use super::*;
2use onestore::{FileDataReference, IdStream, ObjectData, PropertySets, ResolvedRevision, Value};
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::{BTreeMap, BTreeSet};
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8#[serde(untagged, deny_unknown_fields)]
9enum Content {
10 Legacy([u8; 32]),
11 Semantic { sha256: [u8; 32] },
12}
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16struct 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)]
26pub struct TreeEdit {
27 pub intent: onestore::TreeEdit,
28 observed: Observed,
29 created: BTreeSet<ExGuid>,
30}
31
32fn 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
162fn 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
224fn 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
235impl 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
248impl 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)]
331mod 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 @@
1use super::*;
2use 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
14pub(super) struct Signal {
15 stopped: AtomicBool,
16 sender: SyncSender<()>,
17}
18
19impl 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.
28pub struct SyncWorker {
29 signal: Arc<Signal>,
30 thread: Option<JoinHandle<Result<()>>>,
31}
32
33impl 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
54impl Drop for SyncWorker {
55 fn drop(&mut self) {
56 self.signal.stopped.store(true, Ordering::Release);
57 self.signal.wake();
58 }
59}
60
61impl 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 @@
1use onestore_offline::{EditStatus, Error, Operation, Recovery, Replica};
2use std::{
3 fs, io,
4 path::Path,
5 sync::{Barrier, mpsc},
6 time::Duration,
7};
8
9const FIXTURE: &str = "../../corpus/native-external-assets/notebook";
10const LEGACY: &str = "../../corpus/offline-v4/live.sqlite";
11
12fn 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
19fn 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
31struct Payload(Option<io::Result<Vec<u8>>>);
32impl 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]
42fn 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]
106fn 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]
123fn 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]
155fn 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]
172fn 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]
226fn 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]
249fn 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]
288fn 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]
336fn 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"]
410fn 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 @@
1use onestore::{
2 ExGuid, RevisionIndex, Store,
3 document::{Document, Kind},
4};
5use onestore_offline::{Error, Replica};
6use std::{
7 collections::BTreeSet,
8 fs,
9 io::ErrorKind,
10 sync::Barrier,
11 time::{Duration, Instant},
12};
13
14fn 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]
36fn 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]
85fn 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]
116fn 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]
177fn 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]
225fn 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]
322fn 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"]
399fn 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]
573fn 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]
712fn 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]
799fn 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 @@
1use crate::disk;
2use onestore::{
3 CommitError, ExGuid, Insertion, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store,
4 document::{Document, Kind},
5};
6use onestore_offline::{EditStatus, Operation, Remote, Replica};
7use std::{io, sync::LazyLock};
8
9#[path = "../../../onestore/tests/support/current.rs"]
10mod current;
11
12static 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
24fn 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
44struct Session<'a> {
45 disk: &'a mut disk::Disk,
46 operation: Option<&'a Operation>,
47}
48
49impl 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
107pub 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 @@
1use crate::disk;
2use onestore::{
3 CommitError, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, TreeEdit,
4 document::{Document, Kind},
5};
6use onestore_offline::{EditStatus, Operation, Remote, Replica};
7use std::{io, sync::LazyLock};
8
9static 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
34fn 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
68struct Session<'a> {
69 disk: &'a mut disk::Disk,
70 operation: Option<&'a Operation>,
71}
72
73impl 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
138pub 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 @@
1use onestore::{
2 CommitError, CommitIo, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store,
3 document::{Document, Kind},
4};
5use onestore_offline::{ConflictKind, EditStatus, Error, Remote, Replica};
6use std::io;
7
8#[path = "../../onestore/tests/support/disk.rs"]
9mod disk;
10#[path = "sync/outline.rs"]
11mod outline;
12#[path = "sync/page.rs"]
13mod page;
14#[path = "support/page_schedule.rs"]
15mod page_schedule;
16#[path = "sync/pages.rs"]
17mod pages;
18#[path = "sync/tree.rs"]
19mod tree;
20#[path = "support/tree_schedule.rs"]
21mod tree_schedule;
22
23mod 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)]
968enum Fault {
969 #[default]
970 None,
971 Before,
972 UnknownBefore,
973 UnknownAfter,
974 Committed,
975 PanicBefore,
976 PanicAfter,
977 Confirm,
978 ConfirmCommitted,
979}
980
981struct Server {
982 visible: Vec<u8>,
983 durable: Vec<u8>,
984 fault: Fault,
985 publications: usize,
986 confirmations: usize,
987}
988
989impl 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
1001fn failure(state: CommitState) -> CommitError {
1002 CommitError {
1003 state,
1004 error: io::Error::from(io::ErrorKind::ConnectionAborted),
1005 }
1006}
1007
1008impl 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
1030impl 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
1070fn 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]
1091fn 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]
1233fn 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]
1276fn 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]
1309fn 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]
1339fn 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]
1381fn 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]
1411fn 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]
1438fn 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]
1475fn 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]
1518fn 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]
1639fn 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]
1661fn 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]
1709fn 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]
1769fn 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]
1835fn 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]
1870fn 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]
1953fn 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
2024mod 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]
2700fn 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]
2778fn 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]
2864fn 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]
2940fn 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]
2978fn 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]
3016fn 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]
3049fn 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]
3152fn 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]
3203fn 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]
3243fn 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]
3300fn 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]
3320fn 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]
3534fn 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]
3593fn 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]
3714fn 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]
3878fn 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 @@
1use super::*;
2use onestore::OutlineEdit as Change;
3
4pub(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
27pub(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]
38fn 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]
112fn 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]
187fn 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]
267fn 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]
297fn 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]
410fn 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]
468fn 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]
663fn 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 @@
1use super::*;
2use onestore::{Insertion, PageCreation};
3use onestore_offline::{Operation, Recovery};
4use std::collections::BTreeMap;
5
6#[test]
7fn 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]
27fn 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]
101fn 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]
173fn 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]
228fn 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]
289fn 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 @@
1use super::*;
2use onestore::{PageEdit, PagePosition};
3use onestore_offline::{Operation, Recovery};
4use std::collections::BTreeMap;
5
6const SOURCE: &[u8] =
7 include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
8
9fn 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
29fn 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]
46fn 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]
96fn 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]
121fn 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]
178fn 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]
209fn 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]
260fn 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]
316fn 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]
346fn 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]
370fn 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]
414fn 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]
542fn 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 @@
1use super::*;
2use onestore::{Insertion, TreeEdit};
3
4#[test]
5fn 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
20fn 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
37fn 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]
49fn 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]
104fn 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]
148fn 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]
209fn 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]
300fn 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]
382fn 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]
443fn 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]
518fn 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]
545fn 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]
663fn 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]
2name = "onestore-smb"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[dependencies]
8onestore = { path = "../onestore" }
9smb2 = "=0.21.0"
10tokio = { version = "1", features = ["rt-multi-thread", "time"] }
11
12[dev-dependencies]
13serde_json = "1"
crates/onestore-smb/README.md deleted-63
...@@ -1,63 +0,0 @@
1# onestore-smb
2
3Optional blocking SMB access for OneNote sections and table-of-contents files.
4The core `onestore` crate remains independent of network runtimes. This is an
5experimental Rust API with native interoperability evidence in the repository's
6[Milestone 9](../../evidence/MILESTONE9.md).
7
8```no_run
9use onestore_smb::{Client, Credentials};
10use std::time::Duration;
11
12let client = Client::connect(
13 "server:445",
14 "notes",
15 Credentials { username: "user", password: "password", domain: "" },
16 Duration::from_secs(5),
17)?;
18let snapshot = client.read("Personal/Video.one", 64 * 1024 * 1024)?;
19let store = onestore::Store::parse(&snapshot)?;
20let revisions = onestore::RevisionIndex::parse(&store)?;
21let document = onestore::document::Document::parse(&revisions)?;
22# Ok::<(), Box<dyn std::error::Error>>(())
23```
24
25Paths are relative to the share. The read limit bounds the complete physical
26snapshot. Call from a background thread outside a Tokio runtime. Use identities
27from 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
30and persist the intended revision identity before `Client::commit_prepared`.
31`Client::confirm_snapshot` compares and flushes an observed image, then refreshes
32its header version metadata without adding a revision. The caller must first
33establish which intents that image contains and reread before another commit.
34
35Readers use shared native guards while writers publish under native write-open
36and byte-lock exclusion. Maintenance is excluded during each operation; pathname
37identity is checked after acquiring the guards. Connection loss retires the
38client. Reconnect for subsequent operations, and reconcile an `Unknown` edit
39before retrying it. The transport does not automatically replay requests.
40
41`Client::read_dir(path, entry_limit)` enumerates a directory, including the share
42root with an empty path. It follows every response page and returns no partial
43list on interruption, entry-limit overflow or close failure. Entries retain exact
44Unicode names, observed sizes and MS-FSCC attributes, including directory/reparse
45flags. Concurrent directory changes are not an atomic snapshot; repeated names
46are rejected with `ResourceBusy`. Notebook identities come from the files, not
47directory names or sizes. Missing paths, denied access and non-directory paths
48have distinct I/O error kinds.
49
50`Client::read_asset(path, byte_limit)` reads an external payload under a read-only
51share handle that excludes writes and deletion. Empty files succeed; limits,
52interrupted reads and failed close never return partial bytes. This payload read
53does 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
56Linux lab VM against its filesystem listing and interrupts directory requests,
57responses and close. It creates synthetic files in that VM; the caller retains
58responsibility for VM teardown. The parser also has bounded-record/truncation
59tests independent of the server.
60
61Device and simulator builds link for iOS. Native acceptance uses disposable
62OneNote 2010 clients and Samba; it does not establish on-device execution or
63physical power-loss durability.
crates/onestore-smb/examples/smb_concurrent_client.rs deleted-22
...@@ -1,22 +0,0 @@
1#[path = "../../onestore/examples/support/concurrent.rs"]
2mod concurrent;
3
4use onestore_smb::{Client, Credentials};
5use std::{env, time::Duration};
6
7fn 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"]
2mod concurrent;
3
4use onestore::{
5 CommitError, CommitState, ExGuid, RevisionIndex, Store,
6 document::{Document, Kind},
7};
8use onestore_smb::{Client, Credentials};
9use serde_json::json;
10use std::{
11 cell::RefCell,
12 env, io, thread,
13 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
14};
15
16fn 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.
34fn 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
50fn 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]
251fn 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 @@
1use super::*;
2use smb2::msg::query_directory::{
3 FileInformationClass, QueryDirectoryFlags, QueryDirectoryRequest, QueryDirectoryResponse,
4};
5use std::collections::BTreeMap;
6
7/// Observed directory metadata, not a stable notebook identity or a file snapshot.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub 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
16impl 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
104fn 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)]
148mod 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
4use onestore::{CommitError, CommitIo, CommitState, ExGuid};
5use 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};
25use std::{io, ops::Range, sync::Mutex, time::Duration};
26use tokio::runtime::{Handle, Runtime};
27
28mod directory;
29pub use directory::DirectoryEntry;
30
31#[derive(Default)]
32pub 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.
41pub struct Client {
42 connection: Mutex<Option<Connection>>,
43 tree: Tree,
44 timeout: Duration,
45 runtime: Mutex<Option<Runtime>>,
46}
47
48impl 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
322impl Drop for Client {
323 fn drop(&mut self) {
324 self.retire();
325 }
326}
327
328struct File<'a> {
329 client: &'a Client,
330 id: Option<FileId>,
331}
332impl 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}
421impl Drop for File<'_> {
422 fn drop(&mut self) {
423 let _ = self.release();
424 }
425}
426fn 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
440impl 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)]
517mod tests;
crates/onestore-smb/src/tests.rs deleted-507
...@@ -1,507 +0,0 @@
1use super::*;
2use onestore::{
3 RevisionIndex, Store,
4 document::{Document, Kind},
5};
6use std::{
7 fs,
8 time::{Instant, SystemTime, UNIX_EPOCH},
9};
10
11mod faults;
12
13#[test]
14#[ignore = "requires an owned Samba directory and ONESTORE_SMB_DIRECTORY_ORACLE from its local filesystem"]
15fn 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"]
82fn 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
94fn 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}
103fn 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}
131fn 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"]
153fn 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"]
349fn 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"]
386fn 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"]
435fn 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]
470fn 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 @@
1use super::*;
2use serde_json::{Value, json};
3use std::{collections::BTreeMap, path::Path, process::Child};
4
5#[derive(Clone)]
6struct Snapshot {
7 content: BTreeMap<ExGuid, String>,
8 modified: BTreeMap<(ExGuid, ExGuid), u32>,
9}
10
11fn 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
68fn stamp() -> u32 {
69 (SystemTime::now()
70 .duration_since(UNIX_EPOCH)
71 .unwrap()
72 .as_secs()
73 - 315532800)
74 .try_into()
75 .unwrap()
76}
77
78fn 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]
113fn 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
137struct Proxy(Child);
138impl Drop for Proxy {
139 fn drop(&mut self) {
140 let _ = self.0.kill();
141 let _ = self.0.wait();
142 }
143}
144
145fn 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
154fn 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
174fn 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"]
204fn 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"]
267fn 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.
23prototypes belong in sibling directories under `crates/` and depend on23prototypes 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.
25The consumer boundary and API tradeoffs are recorded in [API-AUDIT.md](../../evidence/API-AUDIT.md).25The 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 network27[`notebook`](../notebook/README.md) provides notebook discovery, optional embedded
28access; [`onestore-offline`](../onestore-offline/README.md) provides local28network access (feature `smb`) and local SQLite persistence and reconnect reconciliation for text, insertion and formatting.
29SQLite persistence and reconnect reconciliation for text, insertion and formatting.
30Shared native fixtures, specifications, evidence and Python/VM tools stay at the29Shared native fixtures, specifications, evidence and Python/VM tools stay at the
31repository root; `fuzz/` remains an independent cargo-fuzz workspace.30repository root; `fuzz/` remains an independent cargo-fuzz workspace.
3231
...@@ -143,7 +142,7 @@ Ambiguous ancestry, unsupported indentation transitions and unknown implicit...@@ -143,7 +142,7 @@ Ambiguous ancestry, unsupported indentation transitions and unknown implicit
143font/language inheritance reject before I/O. This is a logical join, so keyboard142font/language inheritance reject before I/O. This is a logical join, so keyboard
144actions that only change list or indentation state remain separate operations.143actions that only change list or indentation state remain separate operations.
145Generated fields, protected targets and unsupported run-data boundary changes are144Generated fields, protected targets and unsupported run-data boundary changes are
146rejected before publication. The [offline crate](../onestore-offline/README.md)145rejected before publication. The [notebook crate](../notebook/README.md)
147documents durable local operations and reconciliation. The146documents 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)
149includes twelve mixed native/Rust clients, outages, lost replies and native revision retirement.148includes 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
161cargo run --example inventory -- /tmp/one-demo/synthetic.one160cargo run --example inventory -- /tmp/one-demo/synthetic.one
162cargo run --example inspect -- /tmp/one-demo/synthetic.one161cargo run --example inspect -- /tmp/one-demo/synthetic.one
163cargo run --example document -- /tmp/one-demo/synthetic.one /tmp/one-model162cargo run --example document -- /tmp/one-demo/synthetic.one /tmp/one-model
164cargo build -p onestore-diagnostic163cargo build -p notebook --bin onestore-diagnostic
165python3 tools/notebook_report.py /tmp/one-demo /tmp/one-report --timezone America/Los_Angeles164python3 tools/notebook_report.py /tmp/one-demo /tmp/one-report --timezone America/Los_Angeles
166```165```
167166
...@@ -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
249filesystem's lock implementation.248filesystem's lock implementation.
250249
251For shared network notebooks, use the optional250For 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,
253shared reader guards, writer exclusion and fresh pathname identity checks without252shared reader guards, writer exclusion and fresh pathname identity checks without
254an OS-mounted share. Its [coordination acceptance](../../evidence/MILESTONE9.md) covers native253an OS-mounted share. Its [coordination acceptance](../../evidence/MILESTONE9.md) covers native
255maintenance, mixed readers/writers, reconnects and uncertain publication. The254maintenance, 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]
579579
580[[package]]
581name = "notebook"
582version = "0.1.0"
583dependencies = [
584 "onestore",
585 "rusqlite",
586 "serde",
587 "serde_json",
588 "sha2",
589 "tempfile",
590 "thiserror",
591]
592
580[[package]]593[[package]]
581name = "objc2"594name = "objc2"
582version = "0.6.4"595version = "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]]
661name = "onestore-notebook"
662version = "0.1.0"
663dependencies = [
664 "onestore",
665 "serde",
666 "thiserror",
667]
668
669[[package]]
670name = "onestore-offline"
671version = "0.1.0"
672dependencies = [
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]
682672
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]
11libfuzzer-sys = "0.4"11libfuzzer-sys = "0.4"
12onestore = { path = "../crates/onestore", features = ["protected"] }12onestore = { path = "../crates/onestore", features = ["protected"] }
13onestore-offline = { path = "../crates/onestore-offline" }13notebook = { path = "../crates/notebook" }
14canvas = { path = "../crates/canvas" }14canvas = { path = "../crates/canvas" }
15tempfile = "3"15tempfile = "3"
16md5 = "0.8.1"16md5 = "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;
33
4#[path = "../../crates/onestore/tests/support/disk.rs"]4#[path = "../../crates/onestore/tests/support/disk.rs"]
5mod disk;5mod disk;
6#[path = "../../crates/onestore-offline/tests/support/page_schedule.rs"]6#[path = "../../crates/notebook/tests/support/page_schedule.rs"]
7mod page_schedule;7mod page_schedule;
88
9fuzz_target!(|input: &[u8]| page_schedule::run(input));9fuzz_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};
8use onestore_offline::{EditStatus, Operation, Remote, Replica};8use notebook::{EditStatus, Operation, Remote, Replica};
9use std::{io, sync::LazyLock};9use std::{io, sync::LazyLock};
1010
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;
33
4#[path = "../../crates/onestore/tests/support/disk.rs"]4#[path = "../../crates/onestore/tests/support/disk.rs"]
5mod disk;5mod disk;
6#[path = "../../crates/onestore-offline/tests/support/tree_schedule.rs"]6#[path = "../../crates/notebook/tests/support/tree_schedule.rs"]
7mod tree_schedule;7mod tree_schedule;
88
9fuzz_target!(|input: &[u8]| tree_schedule::run(input));9fuzz_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 |
4444
45To compare an additional **already captured** notebook hierarchy without running45To 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' / name27 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)
142142
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)
9090