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']]