diff --git a/corpus/page-lifecycle/atomic-nesting/README.md b/corpus/page-lifecycle/atomic-nesting/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d9e8322731af28a38b0a498168a634315013107c --- /dev/null +++ b/corpus/page-lifecycle/atomic-nesting/README.md @@ -0,0 +1,21 @@ +# Atomic page nesting + +The candidate nests `child` and `grandchild` from `../03-renamed` beneath the +`Same title` page, at levels 2 and 3. Section series order, section metadata +copies and the two page metadata revisions publish in one transaction. Page +bodies and prior revisions retain their identities and contents. + +`../04-nested` is the independent native control. `cold` is a fresh OneNote +reopen of the Rust candidate; all active page and section objects compare exactly. +The files differ in 48 header bytes, including file identity and version metadata; +all bytes after the header are identical. Both images are retained. The capture's +owned clone was removed; `provenance.json` records its source and teardown. + +Set `ONESTORE_PAGE_BATCH_OUTPUT` to a new directory and run the core unit test +`write::tests::multi::nesting_publishes_section_order_and_page_levels_in_one_transaction` +to export another candidate. Capture it with `tools/native_runner.py INPUT OUTPUT +--expected-pages 9 --collect-notebook`. Run `tools.test_page_lifecycle` after +building workspace examples to compare native navigation and document content. + +This fixture verifies the internal transaction writer. It does not define a +public page operation, offline conflict policy or implicit subpage movement. diff --git a/corpus/page-lifecycle/atomic-nesting/candidate/Lifecycle.one b/corpus/page-lifecycle/atomic-nesting/candidate/Lifecycle.one new file mode 100644 index 0000000000000000000000000000000000000000..2346d929c9c61890154126c9a06812e397fe2ba5 Binary files /dev/null and b/corpus/page-lifecycle/atomic-nesting/candidate/Lifecycle.one differ diff --git a/corpus/page-lifecycle/atomic-nesting/cold/notebook/Lifecycle.one b/corpus/page-lifecycle/atomic-nesting/cold/notebook/Lifecycle.one new file mode 100644 index 0000000000000000000000000000000000000000..11d219817d4146cb738087f0609cadcbc5f1f67f Binary files /dev/null and b/corpus/page-lifecycle/atomic-nesting/cold/notebook/Lifecycle.one differ diff --git a/corpus/page-lifecycle/atomic-nesting/cold/read/hierarchy.xml b/corpus/page-lifecycle/atomic-nesting/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..cee3162c2cb6f48cd98d2a203094c168ec539500 --- /dev/null +++ b/corpus/page-lifecycle/atomic-nesting/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/atomic-nesting/cold/read/page-000.xml b/corpus/page-lifecycle/atomic-nesting/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..af24af8c5d3a9f47a09c0be909d6ddd95b434574 --- /dev/null +++ b/corpus/page-lifecycle/atomic-nesting/cold/read/page-000.xml @@ -0,0 +1,4 @@ + +Body duplicate 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/atomic-nesting/cold/read/page-001.xml b/corpus/page-lifecycle/atomic-nesting/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..6b5681ff2741eaa99b19da951fae047d9d4ae011 --- /dev/null +++ b/corpus/page-lifecycle/atomic-nesting/cold/read/page-001.xml @@ -0,0 +1,4 @@ + +Body automatic 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/atomic-nesting/cold/read/page-002.xml b/corpus/page-lifecycle/atomic-nesting/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..66d3f1defbcd0f4e7396a3deb9bad7706e003e8d --- /dev/null +++ b/corpus/page-lifecycle/atomic-nesting/cold/read/page-002.xml @@ -0,0 +1,4 @@ + +Body trailing 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/atomic-nesting/cold/read/page-003.xml b/corpus/page-lifecycle/atomic-nesting/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..276256f87c398baf4e8640d89c73c23235122a08 --- /dev/null +++ b/corpus/page-lifecycle/atomic-nesting/cold/read/page-003.xml @@ -0,0 +1,4 @@ + +Body child 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/atomic-nesting/cold/read/page-004.xml b/corpus/page-lifecycle/atomic-nesting/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..7d7fc148d5c2592ebdcb54e3d5e4b85bd60a25dc --- /dev/null +++ b/corpus/page-lifecycle/atomic-nesting/cold/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/atomic-nesting/cold/read/page-005.xml b/corpus/page-lifecycle/atomic-nesting/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..51cd13623ae89257836dd6c5ddc99ff7b586dd52 --- /dev/null +++ b/corpus/page-lifecycle/atomic-nesting/cold/read/page-005.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/atomic-nesting/cold/read/page-006.xml b/corpus/page-lifecycle/atomic-nesting/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..b4ebddb8e3f712eb5942149c7fcbd10729cb7076 --- /dev/null +++ b/corpus/page-lifecycle/atomic-nesting/cold/read/page-006.xml @@ -0,0 +1,4 @@ + +Body parent 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/atomic-nesting/cold/read/page-007.xml b/corpus/page-lifecycle/atomic-nesting/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..641c26f8d9f9da7c9adca61fe8a590a0733fd49a --- /dev/null +++ b/corpus/page-lifecycle/atomic-nesting/cold/read/page-007.xml @@ -0,0 +1,4 @@ + +Body grandchild 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/atomic-nesting/cold/read/page-008.xml b/corpus/page-lifecycle/atomic-nesting/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..3ccb2c20596466d6800169729a572240f7dfb1f2 --- /dev/null +++ b/corpus/page-lifecycle/atomic-nesting/cold/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-lifecycle/atomic-nesting/provenance.json b/corpus/page-lifecycle/atomic-nesting/provenance.json new file mode 100644 index 0000000000000000000000000000000000000000..6e32f69b9b3dcc701bcc71136f1de23c7811799a --- /dev/null +++ b/corpus/page-lifecycle/atomic-nesting/provenance.json @@ -0,0 +1,33 @@ +{ + "source": "../03-renamed/notebook/Lifecycle.one", + "native_control": "../04-nested", + "generator": "write::tests::multi::nesting_publishes_section_order_and_page_levels_in_one_transaction", + "capture": "evidence/m10/page-batch-cold", + "run": { + "notebook": "/Users/clo/dev/one/evidence/m10/page-batch-candidate", + "expected_pages": 9, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } + }, + "machine": { + "name": "m6-3d4af21a", + "hostname": "ONE-M6-3D4AF21A" + }, + "teardown": { + "absent": true + }, + "candidate_sha256": "02fc858fb8854dbd14c1d3c58b62fdd653e51216872afc0cd8b8a4b94b651b9d", + "cold_sha256": "f7a0c74f1a10d5933b386a7da7fee1ead860190b8794dc0ad3788e36c50f14eb" +} diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs index fb7dcd9f2162f4860f7ff3884b9989901eb9ff63..aea01dddb259348307d1dd7e34eefe1762710257 100644 --- a/crates/onestore/src/write.rs +++ b/crates/onestore/src/write.rs @@ -511,7 +511,7 @@ impl PropertyObject { bytes: compact_ids, offset: 0, }; - while cursor.offset < cursor.bytes.len() { + while !cursor.bytes.is_empty() { let id = cursor.compact(&source.global_ids)?; added[index].extend_from_slice(&target.reference(id)?); } @@ -586,6 +586,24 @@ pub(crate) fn write_revision( source: &[u8], space: ExGuid, edit: impl FnOnce(&crate::ResolvedRevision<'_>) -> Result>, +) -> Result> { + write_revisions(source, |index| { + let rid = *index + .spaces + .get(&space) + .and_then(|space| space.labels.get(&(ExGuid::default(), 1))) + .ok_or(Error { + offset: 0, + message: "Object space has no active default revision", + })?; + let revision = index.resolve(space, rid)?; + Ok(BTreeMap::from([(space, edit(&revision)?)])) + }) +} + +pub(crate) fn write_revisions( + source: &[u8], + edit: impl FnOnce(&RevisionIndex<'_>) -> Result>>, ) -> Result> { let store = Store::parse(source)?; let is_section = store.header.file_type == FileType::Section; @@ -597,192 +615,7 @@ pub(crate) fn write_revision( } let index = RevisionIndex::parse(&store)?; index.validate_current()?; - let rid = *index - .spaces - .get(&space) - .and_then(|space| space.labels.get(&(ExGuid::default(), 1))) - .ok_or(Error { - offset: 0, - message: "Object space has no active default revision", - })?; - let mut revision = index.resolve(space, rid)?; - let reachable = revision.reachable()?; - let mut replacements = edit(&revision)?; - for (id, replacement) in &replacements { - if let Some(object) = revision.objects.get(id) { - if !reachable.contains(id) { - return Err(Error { - offset: 0, - message: "Object is not reachable in the active revision", - }); - } - if object.jcid & 0x100000 != 0 { - return Err(Error { - offset: 0, - message: "Read-only object requires a new identity", - }); - } - if replacement.jcid != object.jcid || !matches!(object.data, ObjectData::Properties(_)) - { - return Err(Error { - offset: 0, - message: "An existing object's type cannot be changed", - }); - } - } else if !is_section { - return Err(Error { - offset: 0, - message: "New objects require a section file", - }); - } - if replacement.jcid & 0x20000 == 0 || replacement.global_ids.keys().any(|i| *i > 0xffffff) { - return Err(Error { - offset: 0, - message: "Invalid property object declaration", - }); - } - compact(*id, &replacement.global_ids)?; - PropertySets::parse(&replacement.bytes)?; - } - // Native coalescing of duplicate readonly styles can leave dangling references. - let mut aliases = BTreeMap::new(); - for (id, replacement) in &replacements { - if revision.objects.contains_key(id) - || replacement.jcid & 0x100000 == 0 - || PropertySets::parse(&replacement.bytes)? - .sets - .iter() - .flatten() - .any(|p| matches!(p.value, Value::References { .. })) - { - continue; - } - let existing = revision.objects.iter().find_map(|(other, object)| { - (reachable.contains(other) - && object.jcid == replacement.jcid - && object.data == ObjectData::Properties(&replacement.bytes)) - .then_some(*other) - }); - let existing = existing.or_else(|| { - replacements.range(..id).find_map(|(other, object)| { - (object.jcid == replacement.jcid && object.bytes == replacement.bytes) - .then_some(*aliases.get(other).unwrap_or(other)) - }) - }); - if let Some(existing) = existing { - aliases.insert(*id, existing); - } - } - for id in aliases.keys() { - replacements.remove(id); - } - for object in replacements.values_mut() { - let mut remapped = Vec::new(); - for property in PropertySets::parse(&object.bytes)?.sets.iter().flatten() { - if let Value::References { - stream: crate::IdStream::Objects, - compact_ids, - } = property.value - { - for bytes in compact_ids.chunks_exact(4) { - let offset = bytes.as_ptr().addr() - object.bytes.as_ptr().addr(); - let id = crate::bytes::Cursor { bytes, offset }.compact(&object.global_ids)?; - if let Some(existing) = aliases.get(&id) { - remapped.push((offset, *existing)); - } - } - } - } - for (offset, id) in remapped { - let reference = object.reference(id)?; - object.bytes[offset..offset + 4].copy_from_slice(&reference); - } - } - replacements.retain(|id, replacement| { - !revision.objects.get(id).is_some_and(|object| { - object.data == ObjectData::Properties(&replacement.bytes) - && object.global_ids == replacement.global_ids - }) - }); - if replacements.is_empty() { - return Ok(source.to_vec()); - } - let mut changed: BTreeSet<_> = replacements.keys().copied().collect(); - for (id, replacement) in &replacements { - revision.objects.insert( - *id, - crate::Object { - jcid: replacement.jcid, - reference_count: 0, - data: ObjectData::Properties(&replacement.bytes), - global_ids: Arc::clone(&replacement.global_ids), - }, - ); - } - let incoming = revision.reference_counts()?; - if replacements.keys().any(|id| !incoming.contains_key(id)) { - return Err(Error { - offset: 0, - message: "Edited object is not reachable in the resulting revision", - }); - } - for (id, object) in &mut revision.objects { - if reachable.contains(id) || incoming.contains_key(id) { - let count = incoming.get(id).copied().unwrap_or(0); - if object.reference_count != count { - object.reference_count = count; - changed.insert(*id); - } - } - } - - // Native cold-open fails on long dependency chains; cap their depth at 512. - let checkpoint = std::iter::successors(Some(rid), |id| { - index.spaces[&space].revisions[id].dependency - }) - .nth(511) - .is_some(); - let selected: Vec<_> = revision - .objects - .iter() - .filter(|(id, _)| checkpoint || changed.contains(id)) - .collect(); - let toc_table = if checkpoint && !is_section { - if selected.iter().any(|(_, object)| { - object.jcid != 0x20001 || !matches!(object.data, ObjectData::Properties(_)) - }) { - return Err(Error { - offset: 0, - message: "TOC checkpoint requires table-of-contents property objects", - }); - } - let guids: BTreeSet<_> = selected - .iter() - .flat_map(|(_, object)| object.global_ids.values().copied()) - .collect(); - if guids.len() > 0x1000000 { - return Err(Error { - offset: 0, - message: "Global ID table exceeds CompactID capacity", - }); - } - Some( - guids - .into_iter() - .enumerate() - .map(|(i, guid)| (u32::try_from(i).unwrap(), guid)) - .collect::>(), - ) - } else { - None - }; - let mut groups = BTreeMap::<_, Vec<_>>::new(); - for (id, object) in selected { - groups - .entry(toc_table.as_ref().unwrap_or(&object.global_ids)) - .or_default() - .push((*id, object)); - } + let changes = edit(&index)?; let mut output = source.to_vec(); let maximum = store .transaction_fragments @@ -791,205 +624,406 @@ pub(crate) fn write_revision( .map(|entry| u32::from_le_bytes(entry[..4].try_into().unwrap())) .max() .unwrap(); - let new_rid = ExGuid { - guid: fresh_guid()?, - n: 1, - }; - let mut start = Vec::new(); - new_rid.encode(&mut start); - if checkpoint { ExGuid::default() } else { rid }.encode(&mut start); - if !is_section { - start.extend_from_slice(&0_u64.to_le_bytes()); - } - start.extend_from_slice(&1_u32.to_le_bytes()); - start.extend_from_slice(&0_u16.to_le_bytes()); - let mut manifest = vec![node(if is_section { 0x1e } else { 0x1b }, None, &start)?]; - let mut group_counts = Vec::new(); - for (table, objects) in groups { - let mut payload = Vec::new(); - let mut group = if is_section { - ExGuid { - guid: fresh_guid()?, - n: 1, + let mut counts = Vec::new(); + for (space, mut replacements) in changes { + let rid = *index + .spaces + .get(&space) + .and_then(|space| space.labels.get(&(ExGuid::default(), 1))) + .ok_or(Error { + offset: 0, + message: "Object space has no active default revision", + })?; + let mut revision = index.resolve(space, rid)?; + let reachable = revision.reachable()?; + for (id, replacement) in &replacements { + if let Some(object) = revision.objects.get(id) { + if !reachable.contains(id) { + return Err(Error { + offset: 0, + message: "Object is not reachable in the active revision", + }); + } + if object.jcid & 0x100000 != 0 { + return Err(Error { + offset: 0, + message: "Read-only object requires a new identity", + }); + } + if replacement.jcid != object.jcid + || !matches!(object.data, ObjectData::Properties(_)) + { + return Err(Error { + offset: 0, + message: "An existing object's type cannot be changed", + }); + } + } else if !is_section { + return Err(Error { + offset: 0, + message: "New objects require a section file", + }); } - .encode(&mut payload); - vec![node(0xb4, None, &payload)?, node(0x22, None, &[])?] - } else { - vec![node(0x21, None, &[0])?] - }; - for (id, guid) in table { - let mut entry = id.to_le_bytes().to_vec(); - entry.extend_from_slice(guid); - group.push(node(0x24, None, &entry)?); + if replacement.jcid & 0x20000 == 0 + || replacement.global_ids.keys().any(|i| *i > 0xffffff) + { + return Err(Error { + offset: 0, + message: "Invalid property object declaration", + }); + } + compact(*id, &replacement.global_ids)?; + PropertySets::parse(&replacement.bytes)?; + } + // Native coalescing of duplicate readonly styles can leave dangling references. + let mut aliases = BTreeMap::new(); + for (id, replacement) in &replacements { + if revision.objects.contains_key(id) + || replacement.jcid & 0x100000 == 0 + || PropertySets::parse(&replacement.bytes)? + .sets + .iter() + .flatten() + .any(|p| matches!(p.value, Value::References { .. })) + { + continue; + } + let existing = revision.objects.iter().find_map(|(other, object)| { + (reachable.contains(other) + && object.jcid == replacement.jcid + && object.data == ObjectData::Properties(&replacement.bytes)) + .then_some(*other) + }); + let existing = existing.or_else(|| { + replacements.range(..id).find_map(|(other, object)| { + (object.jcid == replacement.jcid && object.bytes == replacement.bytes) + .then_some(*aliases.get(other).unwrap_or(other)) + }) + }); + if let Some(existing) = existing { + aliases.insert(*id, existing); + } + } + for id in aliases.keys() { + replacements.remove(id); + } + for object in replacements.values_mut() { + let mut remapped = Vec::new(); + for property in PropertySets::parse(&object.bytes)?.sets.iter().flatten() { + if let Value::References { + stream: crate::IdStream::Objects, + compact_ids, + } = property.value + { + for bytes in compact_ids.chunks_exact(4) { + let offset = bytes.as_ptr().addr() - object.bytes.as_ptr().addr(); + let id = + crate::bytes::Cursor { bytes, offset }.compact(&object.global_ids)?; + if let Some(existing) = aliases.get(&id) { + remapped.push((offset, *existing)); + } + } + } + } + for (offset, id) in remapped { + let reference = object.reference(id)?; + object.bytes[offset..offset + 4].copy_from_slice(&reference); + } + } + replacements.retain(|id, replacement| { + !revision.objects.get(id).is_some_and(|object| { + object.data == ObjectData::Properties(&replacement.bytes) + && object.global_ids == replacement.global_ids + }) + }); + if replacements.is_empty() { + continue; } - group.push(node(0x28, None, &[])?); - let mut override_crc = u32::MAX; - for (id, object) in objects { - let mut declaration = compact(id, table)?.to_vec(); - override_crc = crc( - override_crc, - &object.reference_count.to_le_bytes(), - FileType::Section, + let mut changed: BTreeSet<_> = replacements.keys().copied().collect(); + for (id, replacement) in &replacements { + revision.objects.insert( + *id, + crate::Object { + jcid: replacement.jcid, + reference_count: 0, + data: ObjectData::Properties(&replacement.bytes), + global_ids: Arc::clone(&replacement.global_ids), + }, ); - match object.data { - ObjectData::File { - reference, - extension, - } => { - declaration.extend_from_slice(&object.jcid.to_le_bytes()); - declaration.extend_from_slice(&object.reference_count.to_le_bytes()); - for bytes in [reference, extension] { - declaration.extend_from_slice( - &u32::try_from(bytes.len() / 2).unwrap().to_le_bytes(), - ); - declaration.extend_from_slice(bytes); + } + let incoming = revision.reference_counts()?; + if replacements.keys().any(|id| !incoming.contains_key(id)) { + return Err(Error { + offset: 0, + message: "Edited object is not reachable in the resulting revision", + }); + } + for (id, object) in &mut revision.objects { + if reachable.contains(id) || incoming.contains_key(id) { + let count = incoming.get(id).copied().unwrap_or(0); + if object.reference_count != count { + object.reference_count = count; + changed.insert(*id); + } + } + } + + // Native cold-open fails on long dependency chains; cap their depth at 512. + let checkpoint = std::iter::successors(Some(rid), |id| { + index.spaces[&space].revisions[id].dependency + }) + .nth(511) + .is_some(); + let selected: Vec<_> = revision + .objects + .iter() + .filter(|(id, _)| checkpoint || changed.contains(id)) + .collect(); + let toc_table = if checkpoint && !is_section { + if selected.iter().any(|(_, object)| { + object.jcid != 0x20001 || !matches!(object.data, ObjectData::Properties(_)) + }) { + return Err(Error { + offset: 0, + message: "TOC checkpoint requires table-of-contents property objects", + }); + } + let guids: BTreeSet<_> = selected + .iter() + .flat_map(|(_, object)| object.global_ids.values().copied()) + .collect(); + if guids.len() > 0x1000000 { + return Err(Error { + offset: 0, + message: "Global ID table exceeds CompactID capacity", + }); + } + Some( + guids + .into_iter() + .enumerate() + .map(|(i, guid)| (u32::try_from(i).unwrap(), guid)) + .collect::>(), + ) + } else { + None + }; + let mut groups = BTreeMap::<_, Vec<_>>::new(); + for (id, object) in selected { + groups + .entry(toc_table.as_ref().unwrap_or(&object.global_ids)) + .or_default() + .push((*id, object)); + } + let new_rid = ExGuid { + guid: fresh_guid()?, + n: 1, + }; + let mut start = Vec::new(); + new_rid.encode(&mut start); + if checkpoint { ExGuid::default() } else { rid }.encode(&mut start); + if !is_section { + start.extend_from_slice(&0_u64.to_le_bytes()); + } + start.extend_from_slice(&1_u32.to_le_bytes()); + start.extend_from_slice(&0_u16.to_le_bytes()); + let mut manifest = vec![node(if is_section { 0x1e } else { 0x1b }, None, &start)?]; + for (table, objects) in groups { + let mut payload = Vec::new(); + let mut group = if is_section { + ExGuid { + guid: fresh_guid()?, + n: 1, + } + .encode(&mut payload); + vec![node(0xb4, None, &payload)?, node(0x22, None, &[])?] + } else { + vec![node(0x21, None, &[0])?] + }; + for (id, guid) in table { + let mut entry = id.to_le_bytes().to_vec(); + entry.extend_from_slice(guid); + group.push(node(0x24, None, &entry)?); + } + group.push(node(0x28, None, &[])?); + let mut override_crc = u32::MAX; + for (id, object) in objects { + let mut declaration = compact(id, table)?.to_vec(); + override_crc = crc( + override_crc, + &object.reference_count.to_le_bytes(), + FileType::Section, + ); + match object.data { + ObjectData::File { + reference, + extension, + } => { + declaration.extend_from_slice(&object.jcid.to_le_bytes()); + declaration.extend_from_slice(&object.reference_count.to_le_bytes()); + for bytes in [reference, extension] { + declaration.extend_from_slice( + &u32::try_from(bytes.len() / 2).unwrap().to_le_bytes(), + ); + declaration.extend_from_slice(bytes); + } + group.push(node(0x73, None, &declaration)?); } - group.push(node(0x73, None, &declaration)?); - } - ObjectData::Properties(bytes) => { - let references = object.references()?; - let flags = u8::from(!references.objects.is_empty()) - | (u8::from( - !references.object_spaces.is_empty() || !references.contexts.is_empty(), - ) << 1); - let data = if toc_table.is_some() { - let mut mapped = bytes.to_vec(); - for property in PropertySets::parse(bytes)?.sets.iter().flatten() { - if let Value::References { compact_ids, .. } = property.value { - let offset = compact_ids.as_ptr().addr() - bytes.as_ptr().addr(); - for (i, value) in compact_ids.chunks_exact(4).enumerate() { - let value = u32::from_le_bytes(value.try_into().unwrap()); - let target = ExGuid { - guid: object.global_ids[&(value >> 8)], - n: value & 255, - }; - mapped[offset + i * 4..offset + i * 4 + 4] - .copy_from_slice(&compact(target, table)?); + ObjectData::Properties(bytes) => { + let references = object.references()?; + let flags = u8::from(!references.objects.is_empty()) + | (u8::from( + !references.object_spaces.is_empty() + || !references.contexts.is_empty(), + ) << 1); + let data = if toc_table.is_some() { + let mut mapped = bytes.to_vec(); + for property in PropertySets::parse(bytes)?.sets.iter().flatten() { + if let Value::References { compact_ids, .. } = property.value { + let offset = + compact_ids.as_ptr().addr() - bytes.as_ptr().addr(); + for (i, value) in compact_ids.chunks_exact(4).enumerate() { + let value = u32::from_le_bytes(value.try_into().unwrap()); + let target = ExGuid { + guid: object.global_ids[&(value >> 8)], + n: value & 255, + }; + mapped[offset + i * 4..offset + i * 4 + 4] + .copy_from_slice(&compact(target, table)?); + } } } - } - append(&mut output, &mapped)? - } else if replacements.contains_key(&id) { - append(&mut output, bytes)? - } else { - Chunk { - offset: u64::try_from(bytes.as_ptr().addr() - source.as_ptr().addr()) + append(&mut output, &mapped)? + } else if replacements.contains_key(&id) { + append(&mut output, bytes)? + } else { + Chunk { + offset: u64::try_from( + bytes.as_ptr().addr() - source.as_ptr().addr(), + ) .unwrap(), - length: u64::try_from(bytes.len()).unwrap(), - } - }; - if is_section { - declaration.extend_from_slice(&object.jcid.to_le_bytes()); - declaration.push(flags); - } else if checkpoint { - let body = 1_u64 | (u64::from(flags & 1) << 16); - declaration.extend_from_slice(&body.to_le_bytes()[..6]); - } else { - declaration.extend_from_slice(&u32::from(flags).to_le_bytes()); - } - declaration.extend_from_slice(&object.reference_count.to_le_bytes()); - let readonly = object.jcid & 0x100000 != 0; - if readonly { - declaration.extend_from_slice(&md5::compute(bytes).0); - } - group.push(node( + length: u64::try_from(bytes.len()).unwrap(), + } + }; if is_section { - if readonly { 0xc5 } else { 0xa5 } + declaration.extend_from_slice(&object.jcid.to_le_bytes()); + declaration.push(flags); } else if checkpoint { - 0x2e + let body = 1_u64 | (u64::from(flags & 1) << 16); + declaration.extend_from_slice(&body.to_le_bytes()[..6]); } else { - 0x42 - }, - Some(Reference::Data(data)), - &declaration, - )?); - } - ObjectData::Encrypted(_) => { - return Err(Error { - offset: 0, - message: "Encrypted objects cannot be checkpointed", - }); + declaration.extend_from_slice(&u32::from(flags).to_le_bytes()); + } + declaration.extend_from_slice(&object.reference_count.to_le_bytes()); + let readonly = object.jcid & 0x100000 != 0; + if readonly { + declaration.extend_from_slice(&md5::compute(bytes).0); + } + group.push(node( + if is_section { + if readonly { 0xc5 } else { 0xa5 } + } else if checkpoint { + 0x2e + } else { + 0x42 + }, + Some(Reference::Data(data)), + &declaration, + )?); + } + ObjectData::Encrypted(_) => { + return Err(Error { + offset: 0, + message: "Encrypted objects cannot be checkpointed", + }); + } } } - } - if is_section { - group.push(node(0xb8, None, &[])?); - let group_id = u32::try_from(group_counts.len()) - .ok() - .and_then(|n| n.checked_add(1)) - .and_then(|n| maximum.checked_add(n)) - .ok_or(Error { - offset: 0, - message: "File-node list identities are exhausted", - })?; - let chunk = append_list(&mut output, group_id, &group)?; - group_counts.push((group_id, group.len())); - manifest.push(node(0xb0, Some(Reference::NodeList(chunk)), &payload)?); - let mut overrides = vec![0; 8]; - overrides.extend_from_slice(&(!override_crc).to_le_bytes()); - manifest.push(node( - 0x84, - Some(Reference::Data(Chunk { - offset: u64::MAX, - length: 0, - })), - &overrides, - )?); - } else { - manifest.extend(group); - } - } - if checkpoint { - for (role, id) in &revision.roots { - let mut payload = Vec::new(); if is_section { - id.encode(&mut payload); + group.push(node(0xb8, None, &[])?); + let group_id = u32::try_from(counts.len()) + .ok() + .and_then(|n| n.checked_add(1)) + .and_then(|n| maximum.checked_add(n)) + .ok_or(Error { + offset: 0, + message: "File-node list identities are exhausted", + })?; + let chunk = append_list(&mut output, group_id, &group)?; + counts.push((group_id, group.len())); + manifest.push(node(0xb0, Some(Reference::NodeList(chunk)), &payload)?); + let mut overrides = vec![0; 8]; + overrides.extend_from_slice(&(!override_crc).to_le_bytes()); + manifest.push(node( + 0x84, + Some(Reference::Data(Chunk { + offset: u64::MAX, + length: 0, + })), + &overrides, + )?); } else { - payload.extend_from_slice(&compact(*id, toc_table.as_ref().unwrap())?); + manifest.extend(group); } - payload.extend_from_slice(&role.to_le_bytes()); - manifest.push(node(if is_section { 0x5a } else { 0x59 }, None, &payload)?); } - } - manifest.push(node(0x1c, None, &[])?); - let root = store.list(store.header.root)?; - let space_node = root - .nodes - .iter() - .find(|node| node.id == 8 && node.fields(&store).exguid() == Ok(space)) - .ok_or(Error { - offset: 0, - message: "Object space is absent from the root list", + if checkpoint { + for (role, id) in &revision.roots { + let mut payload = Vec::new(); + if is_section { + id.encode(&mut payload); + } else { + payload.extend_from_slice(&compact(*id, toc_table.as_ref().unwrap())?); + } + payload.extend_from_slice(&role.to_le_bytes()); + manifest.push(node(if is_section { 0x5a } else { 0x59 }, None, &payload)?); + } + } + manifest.push(node(0x1c, None, &[])?); + let root = store.list(store.header.root)?; + let space_node = root + .nodes + .iter() + .find(|node| node.id == 8 && node.fields(&store).exguid() == Ok(space)) + .ok_or(Error { + offset: 0, + message: "Object space is absent from the root list", + })?; + let space_list = space_node.referenced_list(&store)?; + let revision_node = space_list.iter().rfind(|node| node.id == 0x10).unwrap(); + let Some(Reference::NodeList(manifest_reference)) = revision_node.reference else { + unreachable!() + }; + let revision_list = store.list(manifest_reference)?; + let last_fragment = *revision_list.fragments.last().unwrap(); + let list_start = usize::try_from(last_fragment.offset).unwrap(); + let list_id = + u32::from_le_bytes(source[list_start + 8..list_start + 12].try_into().unwrap()); + let manifest_chunk = append_list(&mut output, list_id, &manifest)?; + let manifest_start = usize::try_from(manifest_chunk.offset).unwrap(); + let sequence = u32::try_from(revision_list.fragments.len()).map_err(|_| Error { + offset: list_start, + message: "File-node fragment sequences are exhausted", })?; - let space_list = space_node.referenced_list(&store)?; - let revision_node = space_list.iter().rfind(|node| node.id == 0x10).unwrap(); - let Some(Reference::NodeList(manifest_reference)) = revision_node.reference else { - unreachable!() - }; - let revision_list = store.list(manifest_reference)?; - let last_fragment = *revision_list.fragments.last().unwrap(); - let list_start = usize::try_from(last_fragment.offset).unwrap(); - let list_id = u32::from_le_bytes(source[list_start + 8..list_start + 12].try_into().unwrap()); - let manifest_chunk = append_list(&mut output, list_id, &manifest)?; - let manifest_start = usize::try_from(manifest_chunk.offset).unwrap(); - let sequence = u32::try_from(revision_list.fragments.len()).map_err(|_| Error { - offset: list_start, - message: "File-node fragment sequences are exhausted", - })?; - output[manifest_start + 12..manifest_start + 16].copy_from_slice(&sequence.to_le_bytes()); - let last_node = revision_list.nodes.last().unwrap(); - let node_header = u32::from_le_bytes( - source[last_node.offset..last_node.offset + 4] - .try_into() - .unwrap(), - ); - let nodes_end = last_node.offset + usize::try_from((node_header >> 10) & 0x1fff).unwrap(); - let tail = list_start + usize::try_from(last_fragment.length).unwrap() - 20; - if tail - nodes_end >= 4 { - output[nodes_end..nodes_end + 4].copy_from_slice(&node(0xff, None, &[])?); + output[manifest_start + 12..manifest_start + 16].copy_from_slice(&sequence.to_le_bytes()); + let last_node = revision_list.nodes.last().unwrap(); + let node_header = u32::from_le_bytes( + source[last_node.offset..last_node.offset + 4] + .try_into() + .unwrap(), + ); + let nodes_end = last_node.offset + usize::try_from((node_header >> 10) & 0x1fff).unwrap(); + let tail = list_start + usize::try_from(last_fragment.length).unwrap() - 20; + if tail - nodes_end >= 4 { + output[nodes_end..nodes_end + 4].copy_from_slice(&node(0xff, None, &[])?); + } + output[tail..tail + 8].copy_from_slice(&manifest_chunk.offset.to_le_bytes()); + output[tail + 8..tail + 12].copy_from_slice(&(manifest_chunk.length as u32).to_le_bytes()); + + counts.push((list_id, revision_list.nodes.len() + manifest.len())); + } + if counts.is_empty() { + return Ok(source.to_vec()); } - output[tail..tail + 8].copy_from_slice(&manifest_chunk.offset.to_le_bytes()); - output[tail + 8..tail + 12].copy_from_slice(&(manifest_chunk.length as u32).to_le_bytes()); let transactions = store.header.transaction_count.checked_add(1).ok_or(Error { offset: 96, @@ -999,10 +1033,7 @@ pub(crate) fn write_revision( let commit_byte = (31 - changed_bytes.leading_zeros()) / 8; let ceiling = transactions | ((1_u32 << (commit_byte * 8)) - 1); let mut entries = Vec::new(); - for (id, count) in group_counts - .into_iter() - .chain([(list_id, revision_list.nodes.len() + manifest.len())]) - { + for (id, count) in counts { entries.extend_from_slice(&id.to_le_bytes()); entries.extend_from_slice( &u32::try_from(count) @@ -1088,6 +1119,6 @@ pub(crate) fn write_revision( output[228..236].copy_from_slice(&generation.to_le_bytes()); let written = Store::parse(&output)?; let written_index = RevisionIndex::parse(&written)?; - written_index.resolve(space, new_rid)?.reachable()?; + written_index.validate_current()?; Ok(output) } diff --git a/crates/onestore/src/write/tests.rs b/crates/onestore/src/write/tests.rs index ae67fe251d1d4d1eaac1938f39cdb402e32fe6b6..704da0bc7f6f4f64c7d34cc456a12627fa91c0fa 100644 --- a/crates/onestore/src/write/tests.rs +++ b/crates/onestore/src/write/tests.rs @@ -1,6 +1,74 @@ use super::patch_properties; use crate::{PropertySets, Value, create::properties}; +mod multi; + +#[test] +fn copying_reference_arrays_remaps_every_entry_in_each_stream() { + use super::PropertyObject; + use std::{collections::BTreeMap, sync::Arc}; + for kind in [9_u32, 11, 13] { + for count in [0_u32, 1, 2, 3, 4, 8, 33] { + for nested in [false, true] { + let property = (kind << 26) | 1; + let references: Vec<_> = (0..count) + .flat_map(|i| (((i % 3) << 8) | i).to_le_bytes()) + .collect(); + let mut bytes = properties(&[(property, references)]).unwrap(); + let selected = if nested { + let parsed = PropertySets::parse(&bytes).unwrap(); + let offset = parsed.root_ids.as_ptr().addr() - bytes.as_ptr().addr() - 2; + bytes.truncate(bytes.len() - parsed.padding.len()); + bytes.splice(offset..offset, [1, 0, 2, 0, 0, 0x44]); + bytes.resize(bytes.len().next_multiple_of(8), 0); + 0x44000002 + } else { + property + }; + let source = PropertyObject { + jcid: 0x6000e, + bytes, + global_ids: Arc::new(BTreeMap::from([ + (0, [1; 16]), + (1, [2; 16]), + (2, [3; 16]), + ])), + }; + let mut target = PropertyObject { + jcid: 0x6000e, + bytes: properties(&[(0x14000003, 47_u32.to_le_bytes().to_vec())]).unwrap(), + global_ids: Arc::new(BTreeMap::from([ + (0, [3; 16]), + (1, [9; 16]), + (2, [1; 16]), + ])), + }; + target.copy_property(&source, selected).unwrap(); + let parsed = PropertySets::parse(&target.bytes).unwrap(); + assert_eq!(parsed.sets[0][0].value, Value::Bytes(&47_u32.to_le_bytes())); + let copied = parsed + .sets + .iter() + .flatten() + .find(|p| p.id == property) + .unwrap(); + let Value::References { compact_ids, .. } = copied.value else { + panic!() + }; + assert_eq!(compact_ids.len(), count as usize * 4); + for (i, compact) in compact_ids.chunks_exact(4).enumerate() { + let compact = u32::from_le_bytes(compact.try_into().unwrap()); + assert_eq!(compact & 255, i as u32); + assert_eq!(target.global_ids[&(compact >> 8)], [(i % 3 + 1) as u8; 16]); + } + let before = target.bytes.clone(); + target.copy_property(&source, selected).unwrap(); + assert_eq!(target.bytes, before); + } + } + } +} + #[test] fn document_insertions_and_formatting_respect_readonly_ancestors() { use super::{PropertyObject, write_revision}; diff --git a/crates/onestore/src/write/tests/multi.rs b/crates/onestore/src/write/tests/multi.rs new file mode 100644 index 0000000000000000000000000000000000000000..191a5d88846f581bba983d8c2eac71cb0fad0e70 --- /dev/null +++ b/crates/onestore/src/write/tests/multi.rs @@ -0,0 +1,302 @@ +use crate::{ + ExGuid, RevisionIndex, Store, + document::{Document, Kind}, + write::{PropertyObject, write_revisions}, +}; +use std::collections::BTreeMap; + +mod disk { + use crate as onestore; + include!("../../../tests/support/disk.rs"); +} + +mod current { + use crate as onestore; + include!("../../../tests/support/current.rs"); +} + +const SOURCE: &[u8] = + include_bytes!("../../../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one"); + +fn nest(source: &[u8]) -> Vec { + write_revisions(source, |index| { + let document = Document::parse(index)?; + let pages = document.pages()?; + assert_eq!(pages.len(), 9); + let section = index.resolve( + index.root, + index.spaces[&index.root].labels[&(ExGuid::default(), 1)], + )?; + let view = &document.spaces[&index.root]; + let view = &view.revisions[&view.contexts[&ExGuid::default()]]; + let root = section.roots[&1]; + let parent = *view.nodes[&root] + .children + .iter() + .find(|id| view.nodes[id].spaces.contains(&pages[3].0)) + .unwrap(); + let mut series = PropertyObject::from_object(§ion.objects[&parent])?; + let mut spaces = Vec::new(); + let mut metadata = Vec::new(); + let mut replacements = BTreeMap::new(); + let mut changes = BTreeMap::new(); + for (ordinal, (sid, page)) in pages[3..6].iter().enumerate() { + spaces.extend_from_slice(&series.reference(*sid)?); + let raw = index.resolve(*sid, index.spaces[sid].labels[&(ExGuid::default(), 1)])?; + let id = raw.roots[&2]; + let mut object = PropertyObject::from_object(&raw.objects[&id])?; + object.set(&[(0x14001dff, &((ordinal + 1) as u32).to_le_bytes())])?; + changes.insert(*sid, BTreeMap::from([(id, object)])); + let mut copy = PropertyObject::from_object(&raw.objects[&id])?; + copy.set(&[(0x14001dff, &((ordinal + 1) as u32).to_le_bytes())])?; + let mut copy_id = ExGuid { + guid: [ + 0x31, 0xc0, 0xa8, 0x22, 0, 0x36, 0xee, 0x42, 0xb7, 0x14, 0xd7, 0xac, 0xda, + 0x24, 0x35, 0xe8, + ], + n: 1, + }; + for (value, salt) in copy_id.guid.iter_mut().zip(page.guid) { + *value ^= salt; + } + copy.reference(copy_id)?; + metadata.extend_from_slice(&series.reference(copy_id)?); + replacements.insert(copy_id, copy); + } + let links = PropertyObject { + jcid: series.jcid, + bytes: crate::create::properties(&[(0x2c001d63, spaces)])?, + global_ids: std::sync::Arc::clone(&series.global_ids), + }; + series + .copy_property(&links, 0x2c001d63) + .expect("copy spaces"); + series + .set(&[(0x24003442, &metadata)]) + .expect("set metadata links"); + replacements.insert(parent, series); + let mut object = PropertyObject::from_object(§ion.objects[&root])?; + let mut children = Vec::new(); + for id in &view.nodes[&root].children { + if *id == parent + || !view.nodes[id] + .spaces + .iter() + .any(|sid| [pages[4].0, pages[5].0].contains(sid)) + { + children.extend_from_slice(&object.reference(*id)?); + } + } + object.set(&[(0x24001c20, &children)])?; + replacements.insert(root, object); + changes.insert(index.root, replacements); + Ok(changes) + }) + .unwrap() +} + +#[test] +fn nesting_publishes_section_order_and_page_levels_in_one_transaction() { + let written = nest(SOURCE); + let before = Store::parse(SOURCE).unwrap(); + let after = Store::parse(&written).unwrap(); + assert_eq!( + after.header.transaction_count, + before.header.transaction_count + 1 + ); + assert_eq!(after.header.generation, before.header.generation + 1); + let old = RevisionIndex::parse(&before).unwrap(); + let new = RevisionIndex::parse(&after).unwrap(); + let document = Document::parse(&new).unwrap(); + let pages = document.pages().unwrap(); + assert_eq!(pages, Document::parse(&old).unwrap().pages().unwrap()); + let mut changed_spaces = 0; + for (sid, space) in &old.spaces { + changed_spaces += usize::from(space.labels != new.spaces[sid].labels); + for rid in space.revisions.keys() { + assert_eq!( + format!("{:?}", old.resolve(*sid, *rid).unwrap()), + format!("{:?}", new.resolve(*sid, *rid).unwrap()) + ); + } + } + assert_eq!(changed_spaces, 3); + for (ordinal, (sid, _)) in pages.iter().enumerate() { + let view = &document.spaces[sid]; + let view = &view.revisions[&view.contexts[&ExGuid::default()]]; + let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else { + panic!() + }; + assert_eq!( + level, + Some(match ordinal { + 4 => 2, + 5 => 3, + _ => 1, + }) + ); + let old_view = old + .resolve(*sid, old.spaces[sid].labels[&(ExGuid::default(), 1)]) + .unwrap(); + let new_view = new + .resolve(*sid, new.spaces[sid].labels[&(ExGuid::default(), 1)]) + .unwrap(); + for (id, object) in &old_view.objects { + if *id != old_view.roots[&2] { + assert_eq!(format!("{object:?}"), format!("{:?}", new_view.objects[id])); + } + } + } + let root = &document.spaces[&document.root]; + let root = &root.revisions[&root.contexts[&ExGuid::default()]]; + assert_eq!( + root.nodes[&root.roots[&1]] + .children + .iter() + .map(|id| root.nodes[id].spaces.len()) + .collect::>(), + [1, 1, 1, 3, 1, 1, 1] + ); + assert_eq!(nest(&written), written); + if let Some(output) = std::env::var_os("ONESTORE_PAGE_BATCH_OUTPUT") { + std::fs::create_dir(&output).unwrap(); + std::fs::write( + std::path::Path::new(&output).join("Lifecycle.one"), + &written, + ) + .unwrap(); + } +} + +#[test] +fn interrupted_multi_space_publication_never_exposes_a_partial_nesting() { + let written = nest(SOURCE); + let old = current::current(SOURCE); + let new = current::current(&written); + assert_ne!(old, new); + for write_limit in [17, 4096] { + let mut complete = disk::Disk { + visible: SOURCE.to_vec(), + durable: SOURCE.to_vec(), + operation: 0, + fail_at: None, + write_limit, + random: 1951, + }; + crate::commit::commit_bytes(&mut complete, SOURCE, &written).unwrap(); + assert_eq!(complete.durable, written); + for fail_at in 1..=complete.operation { + let mut interrupted = disk::Disk { + visible: SOURCE.to_vec(), + durable: SOURCE.to_vec(), + operation: 0, + fail_at: Some(fail_at), + write_limit, + random: 1951 + fail_at as u64, + }; + crate::commit::commit_bytes(&mut interrupted, SOURCE, &written).unwrap_err(); + let observed = current::current(&interrupted.durable); + assert!( + observed == old || observed == new, + "{write_limit}:{fail_at}" + ); + } + } +} + +#[test] +fn empty_batches_and_invalid_spaces_do_not_produce_an_edit() { + assert_eq!( + write_revisions(SOURCE, |_| Ok(BTreeMap::new())).unwrap(), + SOURCE + ); + assert!( + write_revisions(SOURCE, |_| Ok(BTreeMap::from([( + ExGuid::default(), + BTreeMap::new() + )]))) + .is_err() + ); +} + +#[test] +fn repeated_multi_space_edits_cross_counter_carries_and_checkpoint_each_space() { + let mut source = crate::create_section("batch.one", "Preserved text", "Author").unwrap(); + let initial = source.clone(); + let initial_store = Store::parse(&initial).unwrap(); + let initial_index = RevisionIndex::parse(&initial_store).unwrap(); + let mut checkpoints = BTreeMap::new(); + for step in 1_u32..=514 { + let written = write_revisions(&source, |index| { + let mut changes = BTreeMap::new(); + for (sid, space) in &index.spaces { + let raw = index.resolve(*sid, space.labels[&(ExGuid::default(), 1)])?; + let (id, property) = if *sid == index.root { + (raw.roots[&2], 0x14001cbe) + } else { + ( + *raw.objects + .iter() + .find(|(_, object)| object.jcid == 0x6000b) + .unwrap() + .0, + 0x14001d7a, + ) + }; + let mut object = PropertyObject::from_object(&raw.objects[&id])?; + object.set(&[(property, &step.to_le_bytes())])?; + changes.insert(*sid, BTreeMap::from([(id, object)])); + } + Ok(changes) + }) + .unwrap(); + let store = Store::parse(&written).unwrap(); + assert_eq!( + store.header.transaction_count, + initial_store.header.transaction_count + step + ); + assert!(store.checksum_mismatches.is_empty()); + let index = RevisionIndex::parse(&store).unwrap(); + for (sid, space) in &index.spaces { + let rid = space.labels[&(ExGuid::default(), 1)]; + if space.revisions[&rid].dependency.is_none() { + *checkpoints.entry(*sid).or_insert(0) += 1; + } + for old_rid in initial_index.spaces[sid].revisions.keys() { + assert_eq!( + format!("{:?}", initial_index.resolve(*sid, *old_rid).unwrap()), + format!("{:?}", index.resolve(*sid, *old_rid).unwrap()) + ); + } + } + if step == 1 || store.header.transaction_count == 256 || step == 512 { + let old = current::current(&source); + let new = current::current(&written); + let mut complete = disk::Disk { + visible: source.clone(), + durable: source.clone(), + operation: 0, + fail_at: None, + write_limit: 4096, + random: 1952, + }; + crate::commit::commit_bytes(&mut complete, &source, &written).unwrap(); + for fail_at in 1..=complete.operation { + let mut interrupted = disk::Disk { + visible: source.clone(), + durable: source.clone(), + operation: 0, + fail_at: Some(fail_at), + write_limit: 4096, + random: 1952 + fail_at as u64, + }; + crate::commit::commit_bytes(&mut interrupted, &source, &written).unwrap_err(); + let observed = current::current(&interrupted.durable); + assert!(observed == old || observed == new, "{step}:{fail_at}"); + } + } + source = written; + } + assert_eq!(checkpoints.len(), 2); + assert!(checkpoints.values().all(|count| *count == 1)); +} diff --git a/tools/test_page_lifecycle.py b/tools/test_page_lifecycle.py index 31abfd6f8f9c0994bdff9667a09d12f60d2f692c..afef7df90f17a6efe93afcecfdda30df6c7fe301 100644 --- a/tools/test_page_lifecycle.py +++ b/tools/test_page_lifecycle.py @@ -56,6 +56,47 @@ class PageLifecycleTest(unittest.TestCase): for oid in section['nodes'][section['roots']['1']]['children']]) self.assertEqual(groups[0], groups[1]) + def test_atomic_rust_nesting_matches_native_and_survives_cold_reopen(self): + fixture = FIXTURE / 'atomic-nesting' + self.assertEqual((fixture / 'candidate/Lifecycle.one').read_bytes()[1024:], + (fixture / 'cold/notebook/Lifecycle.one').read_bytes()[1024:]) + with TemporaryDirectory() as temporary: + native_read = Path(temporary) / 'native/read' + cold_read = Path(temporary) / 'cold/read' + shutil.copytree(FIXTURE / '04-nested/read', native_read) + shutil.copytree(fixture / 'cold/read', cold_read) + models = [] + for ordinal, source in enumerate((fixture / 'candidate', fixture / 'cold/notebook')): + output = Path(temporary) / str(ordinal) + subprocess.run([EXPORTER, source / 'Lifecycle.one', output], check=True) + models.append(json.loads((output / 'document.json').read_text())) + compare(source, native_read) + compare(source, cold_read) + pages = [list(ordered_pages(model)) for model in models] + expected = list(ordered_pages(self.models['04-nested', ''])) + original = list(ordered_pages(self.models['03-renamed', ''])) + self.assertEqual(len(pages[0]), 9) + for before, native, candidate, cold in zip(original, expected, *pages, strict=True): + sid, _, revision, page = candidate + self.assertEqual((sid, page), (before[0], before[3])) + self.assertEqual((sid, page), (cold[0], cold[3])) + self.assertEqual(dict(walk(revision, page)), dict(walk(before[2], before[3]))) + self.assertEqual(revision['nodes'], cold[2]['nodes']) + self.assertEqual(revision['roots'], cold[2]['roots']) + metadata = revision['nodes'][revision['roots']['2']]['kind'] + self.assertEqual(metadata, native[2]['nodes'][native[2]['roots']['2']]['kind']) + groups = [] + for model in (self.models['04-nested', ''], *models): + _, revision = view(model, model['root']) + groups.append([revision['nodes'][oid]['spaces'] + for oid in revision['nodes'][revision['roots']['1']]['children']]) + self.assertEqual(groups[0], groups[1]) + self.assertEqual(groups[1], groups[2]) + _, candidate = view(models[0], models[0]['root']) + _, cold = view(models[1], models[1]['root']) + self.assertEqual(candidate['nodes'], cold['nodes']) + self.assertEqual(candidate['roots'], cold['roots']) + def test_native_page_styles_titles_order_and_parent_deletion(self): phases = {phase: list(ordered_pages(self.models[phase, ''])) for phase in PHASES} initial = [sid for sid, _, _, _ in phases['02-authored']]