From 8bcca78a05a23695cd0b16382164810da6d0c96e Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 8 Sep 2026 14:28:35 -0700 Subject: [PATCH] fix: reconcile native subtree normalization in offline deletion Compare immutable referenced records by content and normalize absent versus empty child lists without weakening mutable identity or unknown-property checks. Preserve version-eight deletion observations until explicit conflict review. Extend the shared native/Rust workload to ten document phases with independent graph and receipt verification. Retain the native regression and migration controls; offline tests, mixed twelve-client outage run, cold OneNote comparison, ASan schedules, Clippy and iOS checks pass. Assisted-by: gpt-6-astra --- corpus/outline-edit/README.md | 7 + corpus/outline-edit/empty-children/before.one | Bin 0 -> 147336 bytes .../outline-edit/empty-children/intent.json | 27 +++ corpus/outline-edit/empty-children/remote.one | Bin 0 -> 37936 bytes crates/onestore-offline/README.md | 11 +- .../examples/smb_offline_client.rs | 75 +++++- crates/onestore-offline/src/lib.rs | 2 +- crates/onestore-offline/src/tree.rs | 220 ++++++++++++++++-- crates/onestore-offline/tests/cache.rs | 2 +- .../tests/support/tree_schedule.rs | 27 ++- crates/onestore-offline/tests/sync/tree.rs | 105 +++++++++ tools/offline_document_history.py | 65 ++++-- tools/test_offline_document_history.py | 45 +++- 13 files changed, 530 insertions(+), 56 deletions(-) create mode 100644 corpus/outline-edit/empty-children/before.one create mode 100644 corpus/outline-edit/empty-children/intent.json create mode 100644 corpus/outline-edit/empty-children/remote.one diff --git a/corpus/outline-edit/README.md b/corpus/outline-edit/README.md index 289eb81d93d2539ecd768a063bc11f0dfea1b3f6..8ea0c1938367d1eeebccf312f1cf91d5238d1283 100644 --- a/corpus/outline-edit/README.md +++ b/corpus/outline-edit/README.md @@ -1,5 +1,12 @@ # Native outline editing controls +`empty-children` retains a mixed-client deletion regression: `before.one` exposes +transaction 22 of the retained local image; `remote.one` was observed after native +OneNote removed an empty child-list property and coalesced equivalent immutable +styles. The retained intent addresses the same subtree in both files. The offline +test requires deletion and its dependent edit to publish after reopening the +cache, while an actual remote text change retains a content conflict. + OneNote 2010 authored fourteen cases plus an unchanged source page. `before`, `after`, and `cold` retain notebook bytes and independent native XML. The final phase uses a fresh application cache. Cases preserve Unicode, hyperlinks, tags, diff --git a/corpus/outline-edit/empty-children/before.one b/corpus/outline-edit/empty-children/before.one new file mode 100644 index 0000000000000000000000000000000000000000..c6b4fbe2e6be0cc3d274cdb2617ecb66be26d194 GIT binary patch literal 147336 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

4x_k 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 Lc!V5~#Y_JOS^#zY literal 0 HcmV?d00001 diff --git a/crates/onestore-offline/README.md b/crates/onestore-offline/README.md index a283e6034e093233f7b14a5c2680b71fee2dc49f..3b7de1d28cae619876fbb2d18d24fcddcdcb599b 100644 --- a/crates/onestore-offline/README.md +++ b/crates/onestore-offline/README.md @@ -107,16 +107,19 @@ already satisfied move still requires guarded confirmation before acknowledgemen Deletion compares the selected raw property graph, including referenced styles, tags, unknown fields and internal attachments. Changed content produces `ContentChanged`; property order, CompactID numbering and modification timestamps -do not affect that comparison. Missing targets retain `TargetUnavailable`. +do not affect that comparison. Immutable records compare by content, and empty +child lists compare equally to absent child lists. Mutable object identities +remain significant. Missing targets retain `TargetUnavailable`. `rebase_tree_conflict(id, local, remote)` reviews the original move/deletion against both current images. An emptied cell's replacement paragraph/text identities must remain the same before replay or review; later queued edits keep their targets. Uncertain tree attempts require their original revision for confirmation, even when an independent move or deletion has the same visible effect. -Recognized earlier caches migrate transactionally to version eight, which adds -tree intents and content conflicts. The migration retains -images, local IDs, publication attempts, conflicts, +Recognized earlier caches migrate transactionally to version nine. Version-eight +deletion observations retain their original identity-sensitive preconditions; +explicit conflict review upgrades them to the immutable-content comparison. +The migration retains images, local IDs, publication attempts, conflicts, receipts and the autoincrement sequence; it does not reuse acknowledged IDs when the pending queue is empty. diff --git a/crates/onestore-offline/examples/smb_offline_client.rs b/crates/onestore-offline/examples/smb_offline_client.rs index 3afd08aa5ccbfa29883df2225dcb87a6b9614c37..4440ebb379af1b2f9c7c252b0f098919ff6edb41 100644 --- a/crates/onestore-offline/examples/smb_offline_client.rs +++ b/crates/onestore-offline/examples/smb_offline_client.rs @@ -3,7 +3,7 @@ mod concurrent; use onestore::{ CommitError, CommitState, ExGuid, Insertion, ParagraphJoin, ParagraphSplit, PreparedEdit, - RevisionIndex, Store, TextAttribute, document::Document, + RevisionIndex, Store, TextAttribute, TreeEdit, document::Document, }; use onestore_offline::{EditStatus, Error, Remote, Replica, SmbRemote}; use onestore_smb::{Client, Credentials}; @@ -41,7 +41,18 @@ impl DocumentView { } } -const DOCUMENT_OPERATIONS: [&str; 6] = ["insert", "format", "text", "split", "right_text", "join"]; +const DOCUMENT_OPERATIONS: [&str; 10] = [ + "insert", + "format", + "text", + "split", + "right_text", + "nest", + "unnest", + "join", + "tail_split", + "delete", +]; fn now() -> u128 { SystemTime::now() @@ -331,6 +342,8 @@ fn queue_document( }; let end = u32::try_from(text.encode_utf16().count())?; let split = ParagraphSplit::new(insertion.text_object(), end - 2, "Offline document writer")?; + let tail_split = + ParagraphSplit::new(insertion.text_object(), end + 1, "Offline document writer")?; let join = ParagraphJoin::new( insertion.text_object(), split.text_object(), @@ -344,14 +357,50 @@ fn queue_document( let mut ids = [0; DOCUMENT_OPERATIONS.len()]; for (step, id) in ids.iter_mut().enumerate() { let kind = DOCUMENT_OPERATIONS[step]; + let tree = match kind { + "nest" => { + let source = cache.snapshot()?; + let store = Store::parse(&source)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + let section = &document.spaces[&space]; + let view = §ion.revisions[§ion.contexts[&ExGuid::default()]]; + let paragraph = view + .nodes + .iter() + .find(|(_, node)| node.content == [insertion.text_object()]) + .map(|(id, _)| *id) + .ok_or("Missing inserted paragraph")?; + Some(TreeEdit::move_to( + split.object(), + paragraph, + None, + "Offline document writer", + )?) + } + "unnest" => Some(TreeEdit::move_to( + split.object(), + parent, + None, + "Offline document writer", + )?), + "delete" => Some(TreeEdit::delete( + tail_split.object(), + "Offline document writer", + )?), + _ => None, + }; let range = match kind { "text" => end - 3..end, "split" => end - 2..end - 2, + "tail_split" => end + 1..end + 1, "right_text" => 0..2, _ => 1..end - 2, }; let target = if kind == "right_text" { split.text_object() + } else if kind == "delete" { + tail_split.text_object() } else { insertion.text_object() }; @@ -373,7 +422,9 @@ fn queue_document( cache.edit_text(&source, space, target, range.clone(), replacement.unwrap()) } "split" => cache.split(&source, space, &split), + "tail_split" => cache.split(&source, space, &tail_split), "join" => cache.join(&source, space, &join), + "nest" | "unnest" | "delete" => cache.tree(&source, space, tree.as_ref().unwrap()), _ => unreachable!(), }; match result { @@ -384,7 +435,8 @@ fn queue_document( json!({"event":"local_document_commit","id":acknowledged,"operation":operation,"kind":kind, "space":space.to_string(),"object":target.to_string(),"document":insertion.text_object().to_string(), "text":text,"insertion":if kind=="insert" {Some(&insertion)} else {None}, - "split":if kind=="split" {Some(&split)} else {None}, + "split":match kind { "split" => Some(&split), "tail_split" => Some(&tail_split), _ => None }, + "tree":tree, "joined":if kind=="join" {Some(join.texts().map(|id| id.to_string()))} else {None}, "range":[range.start,range.end],"attributes":attributes,"replacement":replacement, "started_us":started,"finished_us":now()}) @@ -555,7 +607,12 @@ fn main() -> Result<(), Box> { let remote = cache.remote_snapshot()?; let current = view(&remote)?; let onestore_offline::Operation::Text(edit) = &intent.operation else { - return Err("Expected text probe intents".into()); + return Err(format!( + "Document intent {} requires review: {:?}", + intent.id, + cache.status(intent.id)? + ) + .into()); }; let at = append_position(&edit.before, ¤t.text, &edit.replacement) .ok_or("Append model disagrees with retained history")?; @@ -713,9 +770,15 @@ mod tests { cache = Replica::open(&path).unwrap(); } let local = cache.snapshot().unwrap(); - assert_eq!(cache.pending().unwrap().len(), 12); + assert_eq!( + cache.pending().unwrap().len(), + 2 * DOCUMENT_OPERATIONS.len() + ); for (step, id) in ids.iter().enumerate() { - remote.remote.lost_reply = matches!(step % 6, 3 | 5); + remote.remote.lost_reply = matches!( + DOCUMENT_OPERATIONS[step % DOCUMENT_OPERATIONS.len()], + "split" | "nest" | "unnest" | "join" | "tail_split" | "delete" + ); let result = cache.sync_once(&mut remote); let state = if result.is_err() { assert!(matches!( diff --git a/crates/onestore-offline/src/lib.rs b/crates/onestore-offline/src/lib.rs index 28746754a78fe2c29ed534a70270b6ddf6b31434..e95c56c6e1c9c577930580b3fcc09c0715d1c6be 100644 --- a/crates/onestore-offline/src/lib.rs +++ b/crates/onestore-offline/src/lib.rs @@ -51,7 +51,7 @@ pub enum Error { type Result = std::result::Result; const APPLICATION_ID: u32 = 0x4f4e454f; -const SCHEMA_VERSION: u32 = 8; +const SCHEMA_VERSION: u32 = 9; /// Text and its observed precondition, retained across cache reopen and rebasing. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] diff --git a/crates/onestore-offline/src/tree.rs b/crates/onestore-offline/src/tree.rs index 614f6f3b7597263331c76c8b45392a6a8d247e83..ab8077974486d99c57f1da6f46ec1fc7557779a0 100644 --- a/crates/onestore-offline/src/tree.rs +++ b/crates/onestore-offline/src/tree.rs @@ -4,13 +4,20 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +enum Content { + Legacy([u8; 32]), + Semantic { sha256: [u8; 32] }, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Observed { path: Vec<(ExGuid, u8)>, siblings: BTreeMap, destination: Vec<(ExGuid, u8)>, - content: Option<[u8; 32]>, + content: Option, } /// A subtree intent with observed placement, deletion content and replacement identities. @@ -22,23 +29,41 @@ pub struct TreeEdit { created: BTreeSet, } -fn fingerprint(store: &Store<'_>, raw: &ResolvedRevision<'_>, root: ExGuid) -> Result<[u8; 32]> { +fn fingerprint( + store: &Store<'_>, + raw: &ResolvedRevision<'_>, + root: ExGuid, + legacy: bool, +) -> Result<[u8; 32]> { let mut objects = BTreeMap::new(); - let mut pending = vec![root]; - while let Some(id) = pending.pop() { + let mut visiting = BTreeSet::new(); + let mut pending = vec![(root, false)]; + while let Some((id, complete)) = pending.pop() { if objects.contains_key(&id) { continue; } let object = &raw.objects[&id]; let references = object.references()?; - pending.extend(&references.objects); + if !complete { + if !visiting.insert(id) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Subtree property references form a cycle", + ) + .into()); + } + pending.push((id, true)); + pending.extend(references.objects.iter().map(|id| (*id, false))); + continue; + } + visiting.remove(&id); let mut hash = Sha256::new(); hash.update(object.jcid.to_le_bytes()); match object.data { ObjectData::Properties(bytes) => { hash.update([0]); let properties = PropertySets::parse(bytes)?; - let mut objects = references.objects.into_iter(); + let mut object_ids = references.objects.into_iter(); let mut spaces = references.object_spaces.into_iter(); let mut contexts = references.contexts.into_iter(); let mut fields = Vec::new(); @@ -54,20 +79,36 @@ fn fingerprint(store: &Store<'_>, raw: &ResolvedRevision<'_>, root: ExGuid) -> R compact_ids, } => { let references = match stream { - IdStream::Objects => &mut objects, + IdStream::Objects => &mut object_ids, IdStream::ObjectSpaces => &mut spaces, IdStream::Contexts => &mut contexts, }; for reference in references.take(compact_ids.len() / 4) { - value.update(reference.guid); - value.update(reference.n.to_le_bytes()); + if !legacy && *stream == IdStream::Objects { + let immutable = + raw.objects[&reference].jcid & 0x100000 != 0; + value.update([u8::from(immutable)]); + if !immutable { + value.update(reference.guid); + value.update(reference.n.to_le_bytes()); + } + value.update(objects[&reference]); + } else { + value.update(reference.guid); + value.update(reference.n.to_le_bytes()); + } } } Value::Sets(_) => {} } - if property.id != 0x14001d7a { - values.insert(property.id, value); + if property.id == 0x14001d7a + || (!legacy + && property.id == 0x24001c20 + && matches!(&property.value, Value::References { compact_ids, .. } if compact_ids.is_empty())) + { + continue; } + values.insert(property.id, value); } fields.push(values); } @@ -106,6 +147,9 @@ fn fingerprint(store: &Store<'_>, raw: &ResolvedRevision<'_>, root: ExGuid) -> R } objects.insert(id, <[u8; 32]>::from(hash.finalize())); } + if !legacy { + return Ok(objects[&root]); + } let mut hash = Sha256::new(); for (id, value) in objects { hash.update(id.guid); @@ -115,7 +159,12 @@ fn fingerprint(store: &Store<'_>, raw: &ResolvedRevision<'_>, root: ExGuid) -> R Ok(hash.finalize().into()) } -fn observe(source: &[u8], space: ExGuid, intent: &onestore::TreeEdit) -> Result> { +fn observe( + source: &[u8], + space: ExGuid, + intent: &onestore::TreeEdit, + legacy: bool, +) -> Result> { let store = Store::parse(source)?; let index = RevisionIndex::parse(&store)?; let document = Document::parse(&index)?; @@ -168,7 +217,12 @@ fn observe(source: &[u8], space: ExGuid, intent: &onestore::TreeEdit) -> Result< .collect(), destination, content: if intent.destination().is_none() { - Some(fingerprint(&store, &index.resolve(space, *rid)?, object)?) + let sha256 = fingerprint(&store, &index.resolve(space, *rid)?, object, legacy)?; + Some(if legacy { + Content::Legacy(sha256) + } else { + Content::Semantic { sha256 } + }) } else { None }, @@ -206,7 +260,7 @@ impl TreeEdit { intent: &onestore::TreeEdit, ) -> Result<(Self, PreparedEdit<'a>)> { let prepared = PreparedEdit::tree(source, space, intent)?; - let observed = observe(source, space, intent)?.ok_or_else(|| { + let observed = observe(source, space, intent, false)?.ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, "Prepared subtree edit has no active target", @@ -229,7 +283,13 @@ impl TreeEdit { source: &'a [u8], space: ExGuid, ) -> Result, ConflictKind>> { - let Some(current) = observe(source, space, &self.intent)? else { + let Some(current) = observe( + source, + space, + &self.intent, + matches!(self.observed.content, Some(Content::Legacy(_))), + )? + else { return Ok(Err(ConflictKind::TargetUnavailable)); }; let prepared = match PreparedEdit::tree(source, space, &self.intent) { @@ -304,7 +364,7 @@ mod tests { guid: [2; 16], n: 7, }; - let hash = |reverse: bool, global: u32, timestamp: u32, change: usize| { + let hash = |reverse: bool, global: u32, timestamp: u32, change: usize, immutable: bool| { let mut fields = vec![ (0x14001d7a, timestamp.to_le_bytes().to_vec()), (0x20000001, vec![]), @@ -317,6 +377,12 @@ mod tests { set(&[(if change == 2 { 0x08000005 } else { 0x88000005 }, vec![])]), ), ]; + if change != 5 { + fields.push((0x24001c20, 0_u32.to_le_bytes().to_vec())); + } + if change == 6 { + fields.push((0x24000007, 0_u32.to_le_bytes().to_vec())); + } if reverse { fields.reverse(); } @@ -351,7 +417,7 @@ mod tests { ( target, Object { - jcid: 0x6000e, + jcid: if immutable { 0x12004d } else { 0x6000e }, reference_count: 1, data: ObjectData::Properties(&child_bytes), global_ids: ids, @@ -359,12 +425,124 @@ mod tests { ), ]), }; - fingerprint(&store, &raw, root).unwrap() + fingerprint(&store, &raw, root, false).unwrap() }; - let original = hash(false, 0, 1, 0); - assert_eq!(original, hash(true, 137, 2, 0)); + let original = hash(false, 0, 1, 0, false); + assert_eq!(original, hash(true, 137, 2, 0, false)); + assert_eq!(original, hash(true, 137, 2, 5, false)); + assert_ne!(original, hash(true, 137, 2, 6, false)); for change in 1..=4 { - assert_ne!(original, hash(true, 137, 2, change)); + assert_ne!(original, hash(true, 137, 2, change, false)); } + let immutable = hash(false, 0, 1, 0, true); + assert_eq!(immutable, hash(true, 137, 2, 4, true)); + for change in 1..=3 { + assert_ne!(immutable, hash(true, 137, 2, change, true)); + } + } + + #[test] + fn version_eight_deletion_observations_survive_migration_and_upgrade_only_after_review() { + struct Server(Vec); + impl Remote for Server { + fn read(&mut self) -> io::Result> { + Ok(self.0.clone()) + } + fn publish( + &mut self, + edit: &PreparedEdit<'_>, + ) -> std::result::Result<(), onestore::CommitError> { + self.0 = edit.as_bytes().to_vec(); + Ok(()) + } + fn confirm( + &mut self, + snapshot: &[u8], + ) -> std::result::Result<(), onestore::CommitError> { + assert!(self.0 == snapshot); + Ok(()) + } + } + let source = onestore::create_section("legacy.one", "AlphaOmega", "Author").unwrap(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (sid, _) = document.pages().unwrap()[0]; + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let text = *view.nodes.iter().find(|(_, node)| matches!(&node.kind, Kind::RichText { text, .. } if text == "AlphaOmega")).unwrap().0; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("legacy.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + cache + .format( + &source, + sid, + text, + 0..10, + &[onestore::TextAttribute::Bold(true)], + ) + .unwrap(); + let split = onestore::ParagraphSplit::new(text, 5, "Author").unwrap(); + cache + .split(&cache.snapshot().unwrap(), sid, &split) + .unwrap(); + let before = cache.snapshot().unwrap(); + let intent = onestore::TreeEdit::delete(split.object(), "Author").unwrap(); + let deletion = cache.tree(&before, sid, &intent).unwrap().unwrap(); + let dependent = cache + .edit_text(&cache.snapshot().unwrap(), sid, text, 0..0, "Local ") + .unwrap() + .unwrap(); + let mut queue = cache.pending().unwrap(); + let Operation::Tree(edit) = &mut queue[2].operation else { + panic!() + }; + edit.observed = observe(&before, sid, &intent, true).unwrap().unwrap(); + let serialized = serde_json::to_string(&queue[2].operation).unwrap(); + let local = cache.snapshot().unwrap(); + drop(cache); + let db = Connection::open(&path).unwrap(); + db.execute( + "UPDATE edits SET operation=?1 WHERE id=?2", + params![serialized, i64::try_from(deletion).unwrap()], + ) + .unwrap(); + db.pragma_update(None, "user_version", 8).unwrap(); + drop(db); + let cache = Replica::open(&path).unwrap(); + assert_eq!(cache.pending().unwrap(), queue); + assert!(cache.snapshot().unwrap() == local); + let mut server = Server(source); + for _ in 0..2 { + assert!(matches!( + cache.sync_once(&mut server).unwrap(), + Some((_, EditStatus::Published { .. })) + )); + } + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((deletion, EditStatus::Conflict(ConflictKind::ContentChanged))) + ); + cache + .rebase_tree_conflict(deletion, &local, &server.0) + .unwrap(); + let queue = cache.pending().unwrap(); + let Operation::Tree(edit) = &queue[0].operation else { + panic!() + }; + assert!(matches!( + edit.observed.content, + Some(Content::Semantic { .. }) + )); + for id in [deletion, dependent] { + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + ); + } + assert_eq!( + paragraph(&server.0, sid, text).unwrap().as_deref(), + Some("Local Alpha") + ); } } diff --git a/crates/onestore-offline/tests/cache.rs b/crates/onestore-offline/tests/cache.rs index ab72febb3523fb4460b9eeb790d3709f98a96d46..dc9efa6f7116b46952eecb7cc41e4343110346f9 100644 --- a/crates/onestore-offline/tests/cache.rs +++ b/crates/onestore-offline/tests/cache.rs @@ -797,7 +797,7 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { #[test] fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_conflicts() { - for (version, ceiling) in [(5, 3), (6, 4), (7, 5)] { + for (version, ceiling) in [(5, 3), (6, 4), (7, 5), (8, 6)] { use onestore_offline::{ConflictKind, EditStatus, Recovery}; use sha2::{Digest, Sha256}; let directory = tempfile::tempdir().unwrap(); diff --git a/crates/onestore-offline/tests/support/tree_schedule.rs b/crates/onestore-offline/tests/support/tree_schedule.rs index 015711a85d75145ba36d6ec292eadbe16996fd38..8032dba58d40dfd020373396b0c058f667615bbb 100644 --- a/crates/onestore-offline/tests/support/tree_schedule.rs +++ b/crates/onestore-offline/tests/support/tree_schedule.rs @@ -105,6 +105,20 @@ impl Remote for Session<'_> { .unwrap(); text.replace_range(0..1, &edit.replacement); } + Operation::Format(format) => { + let [onestore::TextAttribute::FontSize(size)] = format.attributes.as_slice() else { + panic!() + }; + let store = Store::parse(edit.as_bytes()).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let space = &document.spaces[&SOURCE.1]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + assert_eq!( + view.text_runs(format.object).unwrap()[0].format.font_size, + Some(*size) + ); + } _ => panic!(), } assert_eq!(rows(edit.as_bytes()), expected); @@ -163,7 +177,7 @@ pub fn run(input: &[u8]) { &TreeEdit::delete(local[target].0, "Offline").unwrap(), ) .unwrap(), - _ => cache + _ if step[3] & 1 == 0 => cache .edit_text( &snapshot, *sid, @@ -172,6 +186,17 @@ pub fn run(input: &[u8]) { &char::from(b'A' + step[3] % 26).to_string(), ) .unwrap(), + _ => cache + .format( + &snapshot, + *sid, + local[target].1, + 0..1, + &[onestore::TextAttribute::FontSize( + 12.0 + f32::from(step[3] % 20), + )], + ) + .unwrap(), }; let next = cache.pending().unwrap(); assert_eq!(next[..pending.len()], pending); diff --git a/crates/onestore-offline/tests/sync/tree.rs b/crates/onestore-offline/tests/sync/tree.rs index 51047122949425ea702d954bdc09ce80da481c70..b55b304ca3921551c45b1d17173faaed27c1fd60 100644 --- a/crates/onestore-offline/tests/sync/tree.rs +++ b/crates/onestore-offline/tests/sync/tree.rs @@ -100,6 +100,111 @@ fn move_preserves_remote_content_and_dependent_edits_through_reopen() { assert_eq!(server.publications, 2); } +#[test] +fn queued_format_split_and_delete_reconcile_equivalent_immutable_style_identities() { + let (source, sid, _, _, texts) = fixture(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + cache + .format( + &source, + sid, + texts[0], + 0..8, + &[onestore::TextAttribute::Bold(true)], + ) + .unwrap(); + let split = onestore::ParagraphSplit::new(texts[0], 4, "Author").unwrap(); + cache + .split(&cache.snapshot().unwrap(), sid, &split) + .unwrap(); + cache + .tree( + &cache.snapshot().unwrap(), + sid, + &TreeEdit::delete(split.object(), "Author").unwrap(), + ) + .unwrap(); + cache + .edit_text(&cache.snapshot().unwrap(), sid, texts[0], 0..0, "Local ") + .unwrap(); + let queue = cache.pending().unwrap(); + drop(cache); + let mut server = Server::new(&source); + for intent in queue { + let cache = Replica::open(&path).unwrap(); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == intent.id) + ); + } + assert_eq!( + super::outline::node(&server.durable, sid, texts[0])["kind"]["text"], + "Local Orig" + ); +} + +#[test] +fn native_empty_child_list_normalization_preserves_deletion_and_its_dependents() { + let source = include_bytes!("../../../../corpus/outline-edit/empty-children/before.one"); + let remote = include_bytes!("../../../../corpus/outline-edit/empty-children/remote.one"); + let (sid, intent): (ExGuid, TreeEdit) = serde_json::from_str(include_str!( + "../../../../corpus/outline-edit/empty-children/intent.json" + )) + .unwrap(); + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let tail = view.nodes[&intent.object()].content[0]; + let (outline_id, outline) = view + .nodes + .iter() + .find(|(_, node)| node.children.contains(&intent.object())) + .unwrap(); + let left = view.nodes[&outline.children[0]].content[0]; + for changed in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::create(&path, source).unwrap(); + let deletion = cache.tree(source, sid, &intent).unwrap().unwrap(); + let dependent = cache + .edit_text(&cache.snapshot().unwrap(), sid, left, 0..0, "Local ") + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + drop(cache); + let cache = Replica::open(&path).unwrap(); + let mut server = if changed { + let edited = PreparedEdit::text(remote, sid, tail, 0..2, "Changed").unwrap(); + Server::new(edited.as_bytes()) + } else { + Server::new(remote) + }; + if changed { + assert_eq!( + cache.sync_once(&mut server).unwrap(), + Some((deletion, EditStatus::Conflict(ConflictKind::ContentChanged))) + ); + assert_eq!(cache.snapshot().unwrap(), local); + assert_eq!(cache.pending().unwrap().len(), 2); + assert_eq!(server.publications, 0); + } else { + for expected in [deletion, dependent] { + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == expected) + ); + } + assert!(cache.pending().unwrap().is_empty()); + assert_eq!(server.publications, 2); + assert!(!children(&server.durable, sid, *outline_id).contains(&intent.object())); + let node = super::outline::node(&server.durable, sid, left); + assert!(node["kind"]["text"].as_str().unwrap().starts_with("Local ")); + } + } +} + #[test] fn deletion_requires_review_of_remote_content_and_preserves_later_work() { let (source, sid, outline, paragraphs, texts) = fixture(); diff --git a/tools/offline_document_history.py b/tools/offline_document_history.py index b6f0d007c4f66e2d6ad7bee21e2d9cfe430fad45..b46972ee8c83e6e77b52c0b7b1fac5ea031af9f6 100644 --- a/tools/offline_document_history.py +++ b/tools/offline_document_history.py @@ -18,7 +18,8 @@ def characters(observed): def operation_kinds(events): kinds = tuple(events[0].get('document_kinds', ('insert', 'format'))) assert kinds in (('insert', 'format'), ('insert', 'format', 'text'), - ('insert', 'format', 'text', 'split', 'right_text', 'join')), 'Unknown document workload' + ('insert', 'format', 'text', 'split', 'right_text', 'join'), + ('insert', 'format', 'text', 'split', 'right_text', 'nest', 'unnest', 'join', 'tail_split', 'delete')), 'Unknown document workload' return kinds @@ -41,8 +42,9 @@ def document_history(logs, operations): linked = {} for intent, receipt in zip(edits, receipts, strict=True): changed_targets = {intent['object']} - if intent['kind'] == 'split': changed_targets.add(identity(intent['split'], 2)) + if intent['kind'] in ('split', 'tail_split'): changed_targets.add(identity(intent['split'], 2)) if intent['kind'] == 'join': changed_targets = set(intent['joined']) + if intent['kind'] in ('nest', 'unnest'): changed_targets = set() attempts = [row for row in events if row['event'] == 'remote_attempt' and row['revision'] == receipt['revision'] and set(row.get('document_changes') or {}) == changed_targets] if not attempts and intent['kind'] == 'format': @@ -51,10 +53,12 @@ def document_history(logs, operations): assert len(attempts) == 1, 'Document receipt lacks one publication attempt' attempt, = attempts assert attempt['state'] in ('Committed', 'Unknown'), 'Receipt identifies an unpublished document operation' - assert intent['object'] in attempt['documents'] and attempt['document_changes'] == {target: attempt['documents'].get(target) for target in changed_targets}, 'Document publication changed another target' + assert (intent['object'] in attempt['documents']) == (intent['kind'] != 'delete'), 'Document publication retained or lost its target' + assert attempt['document_changes'] == {target: attempt['documents'].get(target) for target in changed_targets}, 'Document publication changed another target' successful = [row for row in events if row['event'] == 'remote_attempt' and row['state'] in ('Committed', 'Unknown') - and row.get('document_changes') == attempt['document_changes']] + and row.get('document_changes') == attempt['document_changes'] + and row.get('document_graph_changes') == attempt.get('document_graph_changes')] assert successful == [attempt], 'Document intent was published or attempted uncertainly more than once' assert intent['started_us'] <= attempt['started_us'] <= attempt['finished_us'] <= receipt['at_us'] and intent['started_us'] <= intent['finished_us'] <= receipt['at_us'], 'Document acknowledgement order is invalid' if attempt['state'] == 'Unknown': @@ -77,7 +81,7 @@ def document_history(logs, operations): linked[intent['id']] = {**attempt, 'acknowledged_us': receipt['at_us'], 'receipt_revision': receipt['revision']} assert {(row['revision'], row['started_us']) for row in events if row['event'] == 'remote_attempt' and row['state'] in ('Committed', 'Unknown') - and row.get('document_changes')} == { + and (row.get('document_changes') or row.get('document_graph_changes'))} == { (attempt['revision'], attempt['started_us']) for attempt in linked.values() }, 'A document publication lacks its recorded intent and receipt' for at in range(0, len(edits), len(kinds)): @@ -125,7 +129,8 @@ def document_history(logs, operations): for state in states.values(): state['parts'] = [(paragraph, target, 0, len(state['characters']))] if replaced and boundaries: - split, right_edit, join = boundaries + split, right_edit, *following = boundaries + join = next(row for row in following if row['kind'] == 'join') assert events[0].get('document_graph') is True, 'Boundary workload requires structural observations' assert all(row.get('document') == target and row['space'] == inserted['space'] for row in edits[at:at + len(kinds)]), 'Boundary operation lost its owning document' @@ -137,17 +142,37 @@ def document_history(logs, operations): assert right_edit['object'] == right and right_edit['range'] == [0, 2] and right_edit['replacement'] == 'B🦋', 'Dependent right edit differs from workload' assert join['object'] == target and join['joined'] == [target, right], 'Join does not retain its original targets' updated = final[:boundary] + [(char, *final[boundary][1:]) for char in 'B🦋'] + final[boundary+2:] - for row, value, parts in [ - (split, final, [(paragraph, target, 0, boundary), (right_paragraph, right, boundary, len(final))]), - (right_edit, updated, [(paragraph, target, 0, boundary), (right_paragraph, right, boundary, len(updated))]), - (join, updated, [(paragraph, target, 0, len(updated))]), - ]: + split_parts = [(paragraph, target, 0, boundary), (right_paragraph, right, boundary, len(final))] + edited_parts = [(paragraph, target, 0, boundary), (right_paragraph, right, boundary, len(updated))] + stages = [(split, final, split_parts, {}), (right_edit, updated, edited_parts, {})] + if len(following) > 1: + nest, unnest, _, tail_split, deleted = following + outline = identity(insertion, 1) if 'Outline' in insertion['placement'] else insertion['parent'] + for row, parent in [(nest, paragraph), (unnest, outline)]: + tree = row['tree'] + assert row['object'] == target and tree['object'] == right_paragraph and tree['author'] == insertion['author'], 'Tree move addresses another subtree or author' + assert tree['placement'] == {'Move': {'parent': parent, 'before': None}}, 'Tree destination differs from workload' + stages += [(nest, updated, edited_parts, {right_paragraph: paragraph}), + (unnest, updated, edited_parts, {})] + stages.append((join, updated, [(paragraph, target, 0, len(updated))], {})) + if len(following) > 1: + tail = tail_split['split'] + tail_text, tail_paragraph = identity(tail, 2), identity(tail, 1) + assert tail_split['object'] == tail['text'] == target and tail['author'] == insertion['author'], 'Tail split addresses another text or author' + assert tail['offset'] == end+1 and tail_split['range'] == [end+1, end+1], 'Tail split boundary differs from workload' + tree = deleted['tree'] + assert deleted['object'] == tail_text and tree['object'] == tail_paragraph and tree['author'] == insertion['author'], 'Deletion addresses another subtree or author' + assert tree['placement'] == 'Delete', 'Deletion became a move' + stages += [(tail_split, updated, [(paragraph, target, 0, len(updated)-1), (tail_paragraph, tail_text, len(updated)-1, len(updated))], {}), + (deleted, updated[:-1], [(paragraph, target, 0, len(updated)-1)], {})] + known_texts = {oid for _, _, pieces, _ in stages for _, oid, _, _ in pieces} + for row, value, parts, parents in stages: attempt = linked[row['id']] assert list(states.values())[-1]['attempt']['finished_us'] <= attempt['started_us'], 'Boundary publication preceded its dependency' - assert set(attempt['documents']) & {target, right} == {oid for _, oid, _, _ in parts}, 'Boundary publication omitted or resurrected a text object' + assert set(attempt['documents']) & known_texts == {oid for _, oid, _, _ in parts}, 'Boundary publication omitted or resurrected a text object' for _, oid, start, stop in parts: assert characters(attempt['documents'][oid]) == value[start:stop], 'Boundary publication differs from its local intent' - states[row['kind']] = {'characters': value, 'parts': parts, 'attempt': attempt} + states[row['kind']] = {'characters': value, 'parts': parts, 'parents': parents, 'attempt': attempt} allocated = {oid for state in states.values() for paragraph, text, _, _ in state['parts'] for oid in (paragraph, text)} existing = {oid for document in documents.values() for state in document['states'].values() for paragraph, text, _, _ in state['parts'] for oid in (paragraph, text)} @@ -177,6 +202,8 @@ def document_history(logs, operations): states = list(document['states'].values()) known_texts = {oid for state in states for _, oid, _, _ in state['parts']} known_paragraphs = {oid for state in states for oid, _, _, _ in state['parts']} + insertion = document['insertion'] + outline = identity(insertion, 1) if 'Outline' in insertion['placement'] else insertion['parent'] if is_read and row['started_us'] > states[0]['attempt']['acknowledged_us']: assert target in observed, 'Reader missed an acknowledged insertion' if target not in observed: @@ -185,7 +212,9 @@ def document_history(logs, operations): actual = {oid: characters(observed[oid]) for oid in known_texts & observed.keys()} matches = [i for i, state in enumerate(states) if actual == {oid: state['characters'][start:stop] for _, oid, start, stop in state['parts']} - and (not structural or known_paragraphs & graph.keys() == {oid for oid, _, _, _ in state['parts']})] + and (not structural or (known_paragraphs & graph.keys() == {oid for oid, _, _, _ in state['parts']} + and all(graph[p]['parent'] == state.get('parents', {}).get(p, outline) + for p, _, _, _ in state['parts'])))] assert matches, 'Reader observed partial or invented document content' if is_read: matches = [i for i in matches if i >= previous.get(target, 0)] @@ -197,16 +226,14 @@ def document_history(logs, operations): assert matches, 'Reader missed acknowledged document content' previous[target] = min(matches) if structural: - insertion = document['insertion'] - outline = insertion['parent'] if 'Outline' in insertion['placement']: - outline = identity(insertion, 1) expected_graph[outline] = {'parent': insertion['parent'], 'children': [], 'content': [], 'child_level': 1, 'position': insertion['placement']['Outline']} assert outline in expected_graph, 'Snapshot omitted the inserted outline' for paragraph, oid, _, _ in states[min(matches)]['parts']: - expected_graph[outline]['children'].append(paragraph) - expected_graph[paragraph] = {'parent': outline, 'children': [], 'content': [oid], + parent = states[min(matches)].get('parents', {}).get(paragraph, outline) + expected_graph[parent]['children'].append(paragraph) + expected_graph[paragraph] = {'parent': parent, 'children': [], 'content': [oid], 'child_level': 1, 'position': None} if structural: assert graph == expected_graph, 'Snapshot contains partial, reordered or invented paragraph structure' diff --git a/tools/test_offline_document_history.py b/tools/test_offline_document_history.py index 76aace55ff7c9990d1d64d4baf94bbbf7cd32fd0..59e33f449ab2da2dc9c33542a36acb61fa74e56c 100644 --- a/tools/test_offline_document_history.py +++ b/tools/test_offline_document_history.py @@ -149,9 +149,48 @@ class DocumentHistoryTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stdout + result.stderr) events = [json.loads(line) for line in result.stdout.splitlines() if line.startswith('{')] documents = document_history({'w0': events}, 2) - self.assertEqual(sum(len(document['states']) for document in documents.values()), 12) - self.assertEqual(sum(row['event'] == 'remote_attempt' and row['state'] == 'Unknown' for row in events), 4) - self.assertEqual(sum(row['event'] == 'remote_confirm' and row['state'] == 'Committed' for row in events), 4) + self.assertEqual(sum(len(document['states']) for document in documents.values()), 20) + self.assertEqual(sum(row['event'] == 'remote_attempt' and row['state'] == 'Unknown' for row in events), 12) + self.assertEqual(sum(row['event'] == 'remote_confirm' and row['state'] == 'Committed' for row in events), 12) + document = next(iter(documents.values())) + states = document['states'] + for phase, next_phase, stale in [('nest', 'unnest', 'right_text'), ('delete', None, 'tail_split')]: + read = copy.deepcopy(states[stale]['attempt']) + started = states[phase]['attempt']['acknowledged_us'] + 1 + if next_phase: + self.assertLess(started + 1, states[next_phase]['attempt']['started_us']) + read.update(event='read', started_us=started, finished_us=started + 1) + logs = {'w0': events, 'r0': [{'event': 'ready', 'document_graph': True}, read, {'event': 'done'}]} + with self.subTest(phase=phase), self.assertRaisesRegex(AssertionError, 'missed acknowledged'): + document_history(logs, 2) + for mutation in ('nest-intent', 'unnest-intent', 'delete-intent', 'tail-boundary', + 'nest-children', 'delete-resurrection', 'graph-delta', 'extra-attempt'): + changed = copy.deepcopy(events) + intents = {row['kind']: row for row in changed if row['event'] == 'local_document_commit' and row['operation'] == 0} + attempts = {kind: next(row for row in changed if row['event'] == 'remote_attempt' and row['revision'] == state['attempt']['revision']) + for kind, state in states.items()} + if mutation == 'nest-intent': + intents['nest']['tree']['placement']['Move']['parent'] = intents['unnest']['tree']['placement']['Move']['parent'] + elif mutation == 'unnest-intent': + intents['unnest']['tree']['placement']['Move']['before'] = intents['unnest']['tree']['object'] + elif mutation == 'delete-intent': + intents['delete']['tree']['object'] = intents['nest']['tree']['object'] + elif mutation == 'tail-boundary': + intents['tail_split']['split']['offset'] += 1 + elif mutation == 'nest-children': + parent = intents['nest']['tree']['placement']['Move']['parent'] + attempts['nest']['document_graph'][parent]['children'] = [] + elif mutation == 'delete-resurrection': + tail = intents['delete']['object'] + attempts['delete']['documents'][tail] = attempts['tail_split']['documents'][tail] + elif mutation == 'graph-delta': + attempts['nest']['document_graph_changes'] = {} + else: + extra = copy.deepcopy(attempts['nest']) + extra.update(revision='unrecorded-tree-revision', state='Committed') + changed.append(extra) + with self.subTest(mutation=mutation), self.assertRaises(AssertionError): + document_history({'w0': changed}, 2) def test_boundary_histories_retain_every_intermediate_graph_and_retired_identity(self): baseline = history(boundaries=True) -- 2.54.0