From cffb05d4803d38e6fbc9acef1aff140f067a5ede Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 8 Sep 2026 20:51:37 -0700 Subject: [PATCH] feat: publish revisions across existing object spaces atomically Assemble revision manifests for multiple spaces behind one transaction-count update while retaining per-space histories, reference counts and checkpoints. Keep the public API unchanged. Verify a three-space page nesting against native OneNote and a fresh cold reopen. Cover every interrupted I/O, 514 paired revisions across the counter carry and both checkpoints, 130 core tests, 99 offline tests, 148 Python tests, Clippy, both iOS targets and 3,687 sanitizer executions. Fix property copying stopping early on multi-reference arrays; cover all three streams with direct and nested regression cases. Assisted-by: gpt-6-astra --- .../page-lifecycle/atomic-nesting/README.md | 21 + .../atomic-nesting/candidate/Lifecycle.one | Bin 0 -> 53992 bytes .../cold/notebook/Lifecycle.one | Bin 0 -> 53992 bytes .../atomic-nesting/cold/read/hierarchy.xml | 2 + .../atomic-nesting/cold/read/page-000.xml | 4 + .../atomic-nesting/cold/read/page-001.xml | 4 + .../atomic-nesting/cold/read/page-002.xml | 4 + .../atomic-nesting/cold/read/page-003.xml | 4 + .../atomic-nesting/cold/read/page-004.xml | 2 + .../atomic-nesting/cold/read/page-005.xml | 2 + .../atomic-nesting/cold/read/page-006.xml | 4 + .../atomic-nesting/cold/read/page-007.xml | 4 + .../atomic-nesting/cold/read/page-008.xml | 2 + .../atomic-nesting/provenance.json | 33 + crates/onestore/src/write.rs | 777 +++++++++--------- crates/onestore/src/write/tests.rs | 68 ++ crates/onestore/src/write/tests/multi.rs | 302 +++++++ tools/test_page_lifecycle.py | 41 + 18 files changed, 901 insertions(+), 373 deletions(-) create mode 100644 corpus/page-lifecycle/atomic-nesting/README.md create mode 100644 corpus/page-lifecycle/atomic-nesting/candidate/Lifecycle.one create mode 100644 corpus/page-lifecycle/atomic-nesting/cold/notebook/Lifecycle.one create mode 100644 corpus/page-lifecycle/atomic-nesting/cold/read/hierarchy.xml create mode 100644 corpus/page-lifecycle/atomic-nesting/cold/read/page-000.xml create mode 100644 corpus/page-lifecycle/atomic-nesting/cold/read/page-001.xml create mode 100644 corpus/page-lifecycle/atomic-nesting/cold/read/page-002.xml create mode 100644 corpus/page-lifecycle/atomic-nesting/cold/read/page-003.xml create mode 100644 corpus/page-lifecycle/atomic-nesting/cold/read/page-004.xml create mode 100644 corpus/page-lifecycle/atomic-nesting/cold/read/page-005.xml create mode 100644 corpus/page-lifecycle/atomic-nesting/cold/read/page-006.xml create mode 100644 corpus/page-lifecycle/atomic-nesting/cold/read/page-007.xml create mode 100644 corpus/page-lifecycle/atomic-nesting/cold/read/page-008.xml create mode 100644 corpus/page-lifecycle/atomic-nesting/provenance.json create mode 100644 crates/onestore/src/write/tests/multi.rs 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 GIT binary patch literal 53992 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 gcdxP2sp?DJQ6DXR&qb=IZ + 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']] -- 2.54.0