From d4411d62387b98a4bdfc83b6aa1b4740c6781d6f Mon Sep 17 00:00:00 2001 From: aseembits93 Date: Wed, 29 Jul 2026 15:46:00 -0700 Subject: [PATCH] Fix Notion nested archive ordering --- crates/loc-cli/tests/e2e_push_workflow.rs | 374 +++++++++++++++++++++- crates/locality-core/src/journal.rs | 34 ++ crates/locality-core/src/undo.rs | 58 +++- crates/locality-core/tests/undo.rs | 209 ++++++++++++ crates/locality-notion/src/apply.rs | 217 +++++++------ crates/locality-notion/tests/apply.rs | 212 +++++++++--- crates/locality-store/src/sqlite.rs | 8 +- crates/locality-store/tests/sqlite.rs | 209 +++++------- docs/cli.md | 2 +- docs/e2e-behavior-coverage.md | 3 +- docs/locality-core.md | 17 +- docs/locality-store.md | 10 +- 12 files changed, 1048 insertions(+), 305 deletions(-) diff --git a/crates/loc-cli/tests/e2e_push_workflow.rs b/crates/loc-cli/tests/e2e_push_workflow.rs index 8adcc318..6663c58e 100644 --- a/crates/loc-cli/tests/e2e_push_workflow.rs +++ b/crates/loc-cli/tests/e2e_push_workflow.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, hash_map::DefaultHasher}; +use std::collections::{BTreeMap, BTreeSet, hash_map::DefaultHasher}; use std::fs; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; @@ -4731,6 +4731,224 @@ fn large_archive_plan_requires_confirm_before_journaled_apply() { assert_eq!(status.summary.dirty, 0, "{status:#?}"); } +#[test] +fn nested_archive_push_and_undo_preserve_hierarchy_and_order() { + let fixture = E2eFixture::new(); + let mut store = InMemoryStateStore::new(); + let api = Arc::new(MutableNotionApi::with_nested_blocks( + vec![ + paragraph_block("survivor-1", "Original survivor."), + paragraph_block_with_children("parent-1", "Parent paragraph."), + ], + BTreeMap::from([ + ( + "parent-1".to_string(), + vec![paragraph_block_with_children("child-1", "Child paragraph.")], + ), + ( + "child-1".to_string(), + vec![paragraph_block("grandchild-1", "Grandchild paragraph.")], + ), + ]), + )); + let connector = NotionConnector::with_api(NotionConfig::default(), api.clone()); + + run_mount( + &mut store, + MountOptions { + mount_id: fixture.mount_id.clone(), + connector: "notion".to_string(), + root: fixture.root.clone(), + remote_root_id: Some(RemoteId::new("page-1")), + connection_id: Some(ConnectionId::new("work")), + read_only: false, + projection: ProjectionMode::PlainFiles, + settings_json: "{}".to_string(), + }, + ) + .expect("mount nested archive fixture"); + run_pull(&mut store, &connector, &fixture.root).expect("pull nested archive page"); + + let page_path = fixture.page_file(); + let original = fs::read_to_string(&page_path).expect("read nested archive page"); + let frontmatter_end = original + .find("\n---\n") + .map(|index| index + "\n---\n".len()) + .expect("canonical frontmatter terminator"); + fs::write( + &page_path, + format!("{}Updated survivor.\n", &original[..frontmatter_end]), + ) + .expect("update survivor and remove nested blocks"); + + let diff = run_diff(&store, &page_path).expect("diff nested archive plan"); + assert!(diff.ok, "{diff:#?}"); + assert_eq!(diff.action, "confirm_plan", "{diff:#?}"); + let plan = diff.plan.as_ref().expect("nested archive plan"); + assert_eq!(plan.summary.blocks_updated, 1, "{plan:#?}"); + assert_eq!(plan.summary.blocks_archived, 3, "{plan:#?}"); + assert_eq!( + plan.operations + .iter() + .filter_map(|operation| match operation { + PushOperationOutput::ArchiveBlock { block_id } => Some(block_id.as_str()), + _ => None, + }) + .collect::>(), + ["parent-1", "child-1", "grandchild-1"], + "planner should preserve parent-first shadow order before connector lowering" + ); + + let push = run_push_with_daemon( + &mut store, + &connector, + &page_path, + PushOptions { + assume_yes: true, + confirm_dangerous: false, + }, + ) + .expect("push nested archive plan"); + assert!(push.ok, "{push:#?}"); + assert_eq!(push.action, "reconciled", "{push:#?}"); + assert_eq!(push.apply_effect_count, 4, "{push:#?}"); + let push_id = push.push_id.clone().expect("nested archive push id"); + + assert_eq!( + api.calls.lock().expect("calls").as_slice(), + [ + WriteCall::Update { + block_id: "survivor-1".to_string(), + }, + WriteCall::Delete { + block_id: "grandchild-1".to_string(), + }, + WriteCall::Delete { + block_id: "child-1".to_string(), + }, + WriteCall::Delete { + block_id: "parent-1".to_string(), + }, + ] + ); + + let status = run_status( + &store, + StatusOptions { + path: Some(page_path.clone()), + ..StatusOptions::default() + }, + ) + .expect("status after nested archive push"); + assert!(status.clean, "{status:#?}"); + assert_eq!(status.summary.dirty, 0, "{status:#?}"); + + let mut undo_applier = ConnectorUndoApplier::new(&connector); + let undo = run_undo_with_applier(&mut store, push_id, &mut undo_applier) + .expect("undo nested archive push"); + assert!(undo.ok, "{undo:#?}"); + assert_eq!(undo.action, "reverse_applied", "{undo:#?}"); + assert_eq!(undo.status, "reverted", "{undo:#?}"); + assert_eq!( + undo.undo_plan + .as_ref() + .expect("nested archive undo plan") + .operations + .iter() + .filter_map(|operation| match operation { + UndoOperationOutput::RestoreArchivedBlock { block_id, .. } => { + Some(block_id.as_str()) + } + _ => None, + }) + .collect::>(), + ["parent-1", "child-1", "grandchild-1"] + ); + + assert_eq!( + api.calls.lock().expect("calls").as_slice(), + [ + WriteCall::Update { + block_id: "survivor-1".to_string(), + }, + WriteCall::Delete { + block_id: "grandchild-1".to_string(), + }, + WriteCall::Delete { + block_id: "child-1".to_string(), + }, + WriteCall::Delete { + block_id: "parent-1".to_string(), + }, + WriteCall::Restore { + block_id: "parent-1".to_string(), + }, + WriteCall::Restore { + block_id: "child-1".to_string(), + }, + WriteCall::Restore { + block_id: "grandchild-1".to_string(), + }, + WriteCall::Update { + block_id: "survivor-1".to_string(), + }, + ] + ); + + let root_children = api + .retrieve_block_children("page-1", None) + .expect("restored root children"); + assert_eq!( + root_children + .results + .iter() + .map(|block| block.id.as_str()) + .collect::>(), + ["survivor-1", "parent-1"] + ); + let parent_children = api + .retrieve_block_children("parent-1", None) + .expect("restored parent children"); + assert_eq!( + parent_children + .results + .iter() + .map(|block| block.id.as_str()) + .collect::>(), + ["child-1"] + ); + let child_children = api + .retrieve_block_children("child-1", None) + .expect("restored child children"); + assert_eq!( + child_children + .results + .iter() + .map(|block| block.id.as_str()) + .collect::>(), + ["grandchild-1"] + ); + + let restored = fs::read_to_string(&page_path).expect("read nested archive undo result"); + for expected in [ + "Original survivor.", + "Parent paragraph.", + "Child paragraph.", + "Grandchild paragraph.", + ] { + assert!(restored.contains(expected), "{restored}"); + } + let restored_status = run_status( + &store, + StatusOptions { + path: Some(page_path), + ..StatusOptions::default() + }, + ) + .expect("status after nested archive undo"); + assert!(restored_status.clean, "{restored_status:#?}"); +} + #[test] fn auto_save_safe_update_reconciles_and_destructive_update_blocks_before_journaled_apply() { let fixture = E2eFixture::new(); @@ -16920,6 +17138,9 @@ impl NotionApi for RecursivePageDirectoryNotionApi { struct MutableNotionApi { page: Mutex, blocks: Mutex>, + nested_children: Mutex>>, + parent_by_block: BTreeMap, + archived_blocks: Mutex>, database: Mutex>, data_source: Mutex>, block_children_calls: AtomicUsize, @@ -16945,9 +17166,16 @@ impl MutableNotionApi { } fn with_page_and_blocks(page: PageDto, blocks: Vec) -> Self { + let parent_by_block = blocks + .iter() + .map(|block| (block.id.clone(), page.id.clone())) + .collect(); Self { page: Mutex::new(page), blocks: Mutex::new(blocks), + nested_children: Mutex::new(BTreeMap::new()), + parent_by_block, + archived_blocks: Mutex::new(BTreeSet::new()), database: Mutex::new(None), data_source: Mutex::new(None), block_children_calls: AtomicUsize::new(0), @@ -16956,6 +17184,21 @@ impl MutableNotionApi { } } + fn with_nested_blocks( + blocks: Vec, + nested_children: BTreeMap>, + ) -> Self { + let mut api = Self::with_blocks(blocks); + for (parent_id, children) in &nested_children { + for child in children { + api.parent_by_block + .insert(child.id.clone(), parent_id.clone()); + } + } + api.nested_children = Mutex::new(nested_children); + api + } + fn with_page_blocks_and_database_schema( page: PageDto, blocks: Vec, @@ -16965,6 +17208,9 @@ impl MutableNotionApi { Self { page: Mutex::new(page), blocks: Mutex::new(blocks), + nested_children: Mutex::new(BTreeMap::new()), + parent_by_block: BTreeMap::new(), + archived_blocks: Mutex::new(BTreeSet::new()), database: Mutex::new(Some(database)), data_source: Mutex::new(Some(data_source)), block_children_calls: AtomicUsize::new(0), @@ -16976,6 +17222,22 @@ impl MutableNotionApi { fn block_children_count(&self) -> usize { self.block_children_calls.load(Ordering::SeqCst) } + + fn is_archived_or_has_archived_ancestor(&self, block_id: &str) -> bool { + let archived = self.archived_blocks.lock().expect("archived blocks"); + let mut current = Some(block_id); + let mut seen = BTreeSet::new(); + while let Some(block_id) = current { + if !seen.insert(block_id) { + break; + } + if archived.contains(block_id) { + return true; + } + current = self.parent_by_block.get(block_id).map(String::as_str); + } + false + } } impl NotionApi for MutableNotionApi { @@ -17026,15 +17288,24 @@ impl NotionApi for MutableNotionApi { ) -> locality_core::LocalityResult { self.block_children_calls.fetch_add(1, Ordering::SeqCst); let page_id = self.page.lock().expect("page").id.clone(); - if block_id == page_id { - Ok(PaginatedListDto { - results: self.blocks.lock().expect("blocks").clone(), - next_cursor: None, - has_more: false, - }) + let results = if block_id == page_id { + self.blocks.lock().expect("blocks").clone() } else { - Ok(PaginatedListDto::default()) - } + self.nested_children + .lock() + .expect("nested children") + .get(block_id) + .cloned() + .unwrap_or_default() + }; + Ok(PaginatedListDto { + results: results + .into_iter() + .filter(|block| !self.is_archived_or_has_archived_ancestor(&block.id)) + .collect(), + next_cursor: None, + has_more: false, + }) } fn search_pages( @@ -17106,6 +17377,52 @@ impl NotionApi for MutableNotionApi { } fn update_block(&self, block_id: &str, body: Value) -> locality_core::LocalityResult { + if body.get("in_trash").and_then(Value::as_bool) == Some(false) { + self.archived_blocks + .lock() + .expect("archived blocks") + .remove(block_id); + if self.is_archived_or_has_archived_ancestor(block_id) { + self.archived_blocks + .lock() + .expect("archived blocks") + .insert(block_id.to_string()); + return Err(locality_core::LocalityError::InvalidState(format!( + "cannot restore block {block_id} beneath an archived ancestor" + ))); + } + self.calls.lock().expect("calls").push(WriteCall::Restore { + block_id: block_id.to_string(), + }); + if let Some(block) = self + .blocks + .lock() + .expect("blocks") + .iter() + .find(|block| block.id == block_id) + .cloned() + { + return Ok(block); + } + for children in self + .nested_children + .lock() + .expect("nested children") + .values() + { + if let Some(block) = children.iter().find(|block| block.id == block_id) { + return Ok(block.clone()); + } + } + return Err(locality_core::LocalityError::InvalidState(format!( + "missing archived block {block_id}" + ))); + } + if self.is_archived_or_has_archived_ancestor(block_id) { + return Err(locality_core::LocalityError::InvalidState(format!( + "cannot update block {block_id} beneath an archived ancestor" + ))); + } self.calls.lock().expect("calls").push(WriteCall::Update { block_id: block_id.to_string(), }); @@ -17115,6 +17432,14 @@ impl NotionApi for MutableNotionApi { *block = paragraph_block(block_id, &text); return Ok(block.clone()); } + drop(blocks); + let mut nested_children = self.nested_children.lock().expect("nested children"); + for children in nested_children.values_mut() { + if let Some(block) = children.iter_mut().find(|block| block.id == block_id) { + *block = paragraph_block(block_id, &text); + return Ok(block.clone()); + } + } Ok(paragraph_block(block_id, &text)) } @@ -17169,12 +17494,28 @@ impl NotionApi for MutableNotionApi { } fn delete_block(&self, block_id: &str) -> locality_core::LocalityResult { + if self.is_archived_or_has_archived_ancestor(block_id) { + return Err(locality_core::LocalityError::InvalidState(format!( + "cannot archive block {block_id} beneath an archived ancestor" + ))); + } self.calls.lock().expect("calls").push(WriteCall::Delete { block_id: block_id.to_string(), }); - let mut blocks = self.blocks.lock().expect("blocks"); - if let Some(index) = blocks.iter().position(|block| block.id == block_id) { - return Ok(blocks.remove(index)); + self.archived_blocks + .lock() + .expect("archived blocks") + .insert(block_id.to_string()); + let blocks = self.blocks.lock().expect("blocks"); + if let Some(block) = blocks.iter().find(|block| block.id == block_id) { + return Ok(block.clone()); + } + drop(blocks); + let nested_children = self.nested_children.lock().expect("nested children"); + for children in nested_children.values() { + if let Some(block) = children.iter().find(|block| block.id == block_id) { + return Ok(block.clone()); + } } Ok(paragraph_block(block_id, "")) } @@ -17316,6 +17657,9 @@ enum WriteCall { Delete { block_id: String, }, + Restore { + block_id: String, + }, } #[derive(Default)] @@ -17577,6 +17921,12 @@ fn paragraph_block(id: &str, text: &str) -> BlockDto { block } +fn paragraph_block_with_children(id: &str, text: &str) -> BlockDto { + let mut block = paragraph_block(id, text); + block.has_children = true; + block +} + fn child_page_block(id: &str, title: &str) -> BlockDto { let mut block = BlockDto { id: id.to_string(), diff --git a/crates/locality-core/src/journal.rs b/crates/locality-core/src/journal.rs index 7893a3c1..d0ce95b0 100644 --- a/crates/locality-core/src/journal.rs +++ b/crates/locality-core/src/journal.rs @@ -239,6 +239,40 @@ pub enum JournalApplyEffect { }, } +impl JournalApplyEffect { + pub fn operation_index(&self) -> usize { + match self { + Self::UpdatedBlock { + operation_index, .. + } + | Self::CreatedBlock { + operation_index, .. + } + | Self::MovedBlock { + operation_index, .. + } + | Self::ArchivedBlock { + operation_index, .. + } + | Self::ArchivedEntity { + operation_index, .. + } + | Self::UpdatedEntityBody { + operation_index, .. + } + | Self::UpdatedProperties { + operation_index, .. + } + | Self::MovedEntity { + operation_index, .. + } + | Self::CreatedEntity { + operation_index, .. + } => *operation_index, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct JournalPreimage { pub entity_id: RemoteId, diff --git a/crates/locality-core/src/undo.rs b/crates/locality-core/src/undo.rs index 4c5f35b1..afebae4e 100644 --- a/crates/locality-core/src/undo.rs +++ b/crates/locality-core/src/undo.rs @@ -118,7 +118,8 @@ pub fn plan_journal_undo(entry: &JournalEntry) -> UndoPlan { let mut operations = Vec::new(); let mut unsupported = Vec::new(); - for (operation_index, operation) in entry.plan.operations.iter().enumerate().rev() { + for operation_index in reverse_apply_operation_indices(entry) { + let operation = &entry.plan.operations[operation_index]; match operation { PushOperation::UpdateBlock { block_id, .. } => { match find_preimage_block(entry, block_id) { @@ -393,6 +394,61 @@ pub fn plan_journal_undo(entry: &JournalEntry) -> UndoPlan { } } +fn reverse_apply_operation_indices(entry: &JournalEntry) -> Vec { + let operation_count = entry.plan.operations.len(); + let fallback = || (0..operation_count).rev().collect::>(); + let non_archive_indices = entry + .plan + .operations + .iter() + .enumerate() + .filter_map(|(index, operation)| { + (!matches!( + operation, + PushOperation::ArchiveBlock { .. } | PushOperation::ArchiveEntity { .. } + )) + .then_some(index) + }) + .collect::>(); + let archive_indices = entry + .plan + .operations + .iter() + .enumerate() + .filter_map(|(index, operation)| { + matches!( + operation, + PushOperation::ArchiveBlock { .. } | PushOperation::ArchiveEntity { .. } + ) + .then_some(index) + }) + .collect::>(); + if archive_indices.is_empty() { + return fallback(); + } + + let mut apply_order = Vec::with_capacity(operation_count); + + for effect in &entry.apply_effects { + let operation_index = effect.operation_index(); + if operation_index < operation_count && !apply_order.contains(&operation_index) { + apply_order.push(operation_index); + } + } + + if apply_order.len() != operation_count + || !apply_order.starts_with(&non_archive_indices) + || apply_order[non_archive_indices.len()..] + .iter() + .any(|index| !archive_indices.contains(index)) + { + return fallback(); + } + + apply_order.reverse(); + apply_order +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct UndoApplyRequest<'a> { pub target_push_id: &'a PushId, diff --git a/crates/locality-core/tests/undo.rs b/crates/locality-core/tests/undo.rs index 9cfdf2ce..39313e0b 100644 --- a/crates/locality-core/tests/undo.rs +++ b/crates/locality-core/tests/undo.rs @@ -537,6 +537,215 @@ fn archive_entity_reverses_to_restore_archived_entity() { ); } +#[test] +fn undo_reverses_complete_journaled_apply_order_instead_of_original_plan_order() { + let operations = vec![ + PushOperation::ArchiveEntity { + entity_id: RemoteId::new("page-1"), + }, + PushOperation::ArchiveBlock { + block_id: RemoteId::new("paragraph-1"), + }, + ]; + let mut entry = journal_entry_with_shadow(operations, shadow_with_frontmatter()); + entry.apply_effects = vec![ + JournalApplyEffect::ArchivedBlock { + operation_id: PushOperationId::for_operation( + &entry.push_id, + 1, + &entry.plan.operations[1], + ), + operation_index: 1, + block_id: RemoteId::new("paragraph-1"), + }, + JournalApplyEffect::ArchivedEntity { + operation_id: PushOperationId::for_operation( + &entry.push_id, + 0, + &entry.plan.operations[0], + ), + operation_index: 0, + entity_id: RemoteId::new("page-1"), + }, + ]; + + let plan = plan_journal_undo(&entry); + + assert_eq!(plan.status, UndoPlanStatus::Complete); + assert_eq!( + plan.operations, + vec![ + UndoOperation::RestoreArchivedEntity { + entity_id: RemoteId::new("page-1"), + expected: EntityUndoState { + parent_id: RemoteId::new("old-parent"), + title: "Roadmap".to_string(), + properties: BTreeMap::from([ + ("Points".to_string(), PropertyValue::Number("2".to_string())), + ( + "Status".to_string(), + PropertyValue::String("Todo".to_string()), + ), + ]), + body: "# Roadmap\n\nOld paragraph.".to_string(), + archived: true, + }, + }, + UndoOperation::RestoreArchivedBlock { + block_id: RemoteId::new("paragraph-1"), + parent_id: RemoteId::new("page-1"), + after: Some(RemoteId::new("heading-1")), + content: "Old paragraph.".to_string(), + native_kind: None, + }, + ] + ); +} + +#[test] +fn nested_archive_effects_reverse_to_ancestor_first_restore_order() { + let operations = vec![ + PushOperation::UpdateBlock { + block_id: RemoteId::new("survivor-1"), + content: "Updated survivor.".to_string(), + }, + PushOperation::ArchiveBlock { + block_id: RemoteId::new("parent-1"), + }, + PushOperation::ArchiveBlock { + block_id: RemoteId::new("child-1"), + }, + PushOperation::ArchiveBlock { + block_id: RemoteId::new("grandchild-1"), + }, + ]; + let shadow = ShadowDocument::from_synced_body( + RemoteId::new("page-1"), + "Original survivor.\n\nParent.\n\nChild.\n\nGrandchild.", + 9, + [ + RemoteId::new("survivor-1"), + RemoteId::new("parent-1"), + RemoteId::new("child-1"), + RemoteId::new("grandchild-1"), + ], + ) + .expect("nested archive shadow"); + let mut entry = journal_entry_with_shadow(operations, shadow); + entry.apply_effects = vec![ + JournalApplyEffect::UpdatedBlock { + operation_id: PushOperationId::for_operation( + &entry.push_id, + 0, + &entry.plan.operations[0], + ), + operation_index: 0, + block_id: RemoteId::new("survivor-1"), + }, + JournalApplyEffect::ArchivedBlock { + operation_id: PushOperationId::for_operation( + &entry.push_id, + 3, + &entry.plan.operations[3], + ), + operation_index: 3, + block_id: RemoteId::new("grandchild-1"), + }, + JournalApplyEffect::ArchivedBlock { + operation_id: PushOperationId::for_operation( + &entry.push_id, + 2, + &entry.plan.operations[2], + ), + operation_index: 2, + block_id: RemoteId::new("child-1"), + }, + JournalApplyEffect::ArchivedBlock { + operation_id: PushOperationId::for_operation( + &entry.push_id, + 1, + &entry.plan.operations[1], + ), + operation_index: 1, + block_id: RemoteId::new("parent-1"), + }, + ]; + + let plan = plan_journal_undo(&entry); + + assert_eq!(plan.status, UndoPlanStatus::Complete); + assert_eq!( + plan.operations + .iter() + .filter_map(|operation| match operation { + UndoOperation::RestoreArchivedBlock { block_id, .. } => { + Some(block_id.as_str()) + } + _ => None, + }) + .collect::>(), + ["parent-1", "child-1", "grandchild-1"] + ); + assert!(matches!( + plan.operations.last(), + Some(UndoOperation::RestoreBlockContent { block_id, content }) + if block_id == &RemoteId::new("survivor-1") + && content == "Original survivor." + )); +} + +#[test] +fn undo_does_not_treat_non_archive_effect_order_as_remote_execution_order() { + let operations = vec![ + PushOperation::UpdateBlock { + block_id: RemoteId::new("a"), + content: "Updated A".to_string(), + }, + PushOperation::UpdateBlock { + block_id: RemoteId::new("b"), + content: "Updated B".to_string(), + }, + ]; + let mut entry = journal_entry_with_shadow(operations, multi_block_shadow()); + entry.apply_effects = vec![ + JournalApplyEffect::UpdatedBlock { + operation_id: PushOperationId::for_operation( + &entry.push_id, + 1, + &entry.plan.operations[1], + ), + operation_index: 1, + block_id: RemoteId::new("b"), + }, + JournalApplyEffect::UpdatedBlock { + operation_id: PushOperationId::for_operation( + &entry.push_id, + 0, + &entry.plan.operations[0], + ), + operation_index: 0, + block_id: RemoteId::new("a"), + }, + ]; + + let plan = plan_journal_undo(&entry); + + assert_eq!(plan.status, UndoPlanStatus::Complete); + assert_eq!( + plan.operations, + vec![ + UndoOperation::RestoreBlockContent { + block_id: RemoteId::new("b"), + content: "B".to_string(), + }, + UndoOperation::RestoreBlockContent { + block_id: RemoteId::new("a"), + content: "A".to_string(), + }, + ] + ); +} + #[test] fn archive_entity_without_entity_preimage_is_blocked() { let mut entry = journal_entry(vec![PushOperation::ArchiveEntity { diff --git a/crates/locality-notion/src/apply.rs b/crates/locality-notion/src/apply.rs index cc2f2b2f..0b243984 100644 --- a/crates/locality-notion/src/apply.rs +++ b/crates/locality-notion/src/apply.rs @@ -10,7 +10,6 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Component, Path}; use locality_connector::{ApplyPlanRequest, ApplyPlanResult, ApplyUndoRequest, ApplyUndoResult}; -use locality_core::canonical::{Directive, parse_directive_line}; use locality_core::journal::JournalApplyEffect; use locality_core::model::RemoteId; use locality_core::planner::{PropertyValue, PushOperation}; @@ -94,6 +93,13 @@ pub fn apply_plan( let mut operation_index = 0usize; while operation_index < request.plan.operations.len() { + if matches!( + request.plan.operations[operation_index], + PushOperation::ArchiveBlock { .. } | PushOperation::ArchiveEntity { .. } + ) { + operation_index += 1; + continue; + } let step = lower_notion_apply_step( &request.plan.operations, operation_index, @@ -257,13 +263,10 @@ pub fn apply_plan( block_id: block_id.clone(), }); } - PushOperation::ArchiveBlock { block_id } => { - api.delete_block(block_id.as_str())?; - effects.push(JournalApplyEffect::ArchivedBlock { - operation_id: request.operation_ids[operation_index].clone(), - operation_index, - block_id: block_id.clone(), - }); + PushOperation::ArchiveBlock { .. } | PushOperation::ArchiveEntity { .. } => { + unreachable!( + "standalone Notion archives are deferred until other writes finish" + ) } PushOperation::UpdateProperties { entity_id, @@ -373,17 +376,6 @@ pub fn apply_plan( entity_id: created_id, }); } - PushOperation::ArchiveEntity { entity_id } => { - api.delete_block(entity_id.as_str())?; - if !changed_remote_ids.contains(entity_id) { - changed_remote_ids.push(entity_id.clone()); - } - effects.push(JournalApplyEffect::ArchivedEntity { - operation_id: request.operation_ids[operation_index].clone(), - operation_index, - entity_id: entity_id.clone(), - }); - } PushOperation::UpdateEntityBody { .. } => { return Err(LocalityError::Unsupported( "whole-entity body updates for Notion", @@ -458,6 +450,33 @@ pub fn apply_plan( } } + for operation_index in + deferred_archive_operation_indices(&request.plan.operations, &block_parents) + { + match &request.plan.operations[operation_index] { + PushOperation::ArchiveBlock { block_id } => { + api.delete_block(block_id.as_str())?; + effects.push(JournalApplyEffect::ArchivedBlock { + operation_id: request.operation_ids[operation_index].clone(), + operation_index, + block_id: block_id.clone(), + }); + } + PushOperation::ArchiveEntity { entity_id } => { + api.delete_block(entity_id.as_str())?; + if !changed_remote_ids.contains(entity_id) { + changed_remote_ids.push(entity_id.clone()); + } + effects.push(JournalApplyEffect::ArchivedEntity { + operation_id: request.operation_ids[operation_index].clone(), + operation_index, + entity_id: entity_id.clone(), + }); + } + _ => unreachable!("deferred Notion archive index must reference an archive operation"), + } + } + for remote_id in &request.plan.affected_entities { if !create_parent_ids.contains(remote_id) && !changed_remote_ids.contains(remote_id) { changed_remote_ids.push(remote_id.clone()); @@ -506,15 +525,14 @@ pub fn apply_undo( UndoOperation::ArchiveCreatedBlock { block_id } => { api.delete_block(block_id.as_str())?; } - UndoOperation::RestoreArchivedBlock { - parent_id, - after, - content, - native_kind, - .. - } => { - let child = restore_archived_block_child(api, content, native_kind.as_deref())?; - api.append_block_children(parent_id.as_str(), append_body(child, after.as_ref()))?; + UndoOperation::RestoreArchivedBlock { block_id, .. } => { + let restored = api.update_block(block_id.as_str(), json!({ "in_trash": false }))?; + if restored.id != block_id.0 || restored.archived || restored.in_trash { + return Err(LocalityError::InvalidState(format!( + "Notion did not restore archived block `{}` in place", + block_id.0 + ))); + } } UndoOperation::ArchiveCreatedEntity { entity_id, .. } => { api.delete_block(entity_id.as_str())?; @@ -631,13 +649,6 @@ fn prevalidate_undo_plan(api: &dyn NotionApi, plan: &UndoPlan) -> LocalityResult } parse_supported_block(content, None, None)?; } - UndoOperation::RestoreArchivedBlock { - content, - native_kind, - .. - } => { - restore_archived_block_child(api, content, native_kind.as_deref())?; - } UndoOperation::RestoreProperties { entity_id, previous, @@ -669,6 +680,7 @@ fn prevalidate_undo_plan(api: &dyn NotionApi, plan: &UndoPlan) -> LocalityResult return Err(LocalityError::Unsupported(unsupported_undo_name(operation))); } UndoOperation::ArchiveCreatedBlock { .. } + | UndoOperation::RestoreArchivedBlock { .. } | UndoOperation::ArchiveCreatedEntity { .. } | UndoOperation::RestoreArchivedEntity { .. } => {} } @@ -719,75 +731,6 @@ fn prevalidate_table_update( Ok(()) } -fn restore_archived_block_child( - api: &dyn NotionApi, - content: &str, - native_kind: Option<&str>, -) -> LocalityResult { - let trimmed = content.trim(); - if let Some(directive) = parse_directive_line(trimmed, 1) { - return restore_directive_child(&directive); - } - - if native_kind.unwrap_or("link_to_page") == "link_to_page" - && let Some((label, href, consumed)) = parse_markdown_link(trimmed) - && consumed == trimmed.len() - && let Some(target_id) = notion_page_id_from_href(&href) - { - match label.as_str() { - "Linked page" => { - return Ok(json!({ - "object": "block", - "type": "link_to_page", - "link_to_page": { - "type": "page_id", - "page_id": target_id, - }, - })); - } - "Linked database" => { - return Ok(json!({ - "object": "block", - "type": "link_to_page", - "link_to_page": { - "type": "database_id", - "database_id": target_id, - }, - })); - } - _ => {} - } - } - - parse_append_block(api, content, None).map(|patch| patch.append_child()) -} - -fn restore_directive_child(directive: &Directive) -> LocalityResult { - let directive_type = directive - .directive_type - .as_deref() - .ok_or(LocalityError::Unsupported( - "restoring archived Notion directive blocks without a type", - ))?; - match directive_type { - "table_of_contents" => { - let payload = directive - .attributes - .get("color") - .map(|color| json!({ "color": color })) - .unwrap_or_else(|| json!({})); - Ok(json!({ - "object": "block", - "type": "table_of_contents", - "table_of_contents": payload, - })) - } - _ => Err(LocalityError::Unsupported( - "restoring this archived Notion directive block type", - )), - } -} - fn validate_operation_ids(request: &ApplyPlanRequest<'_>) -> LocalityResult<()> { if request.operation_ids.len() != request.plan.operations.len() { return Err(LocalityError::InvalidState(format!( @@ -825,6 +768,41 @@ fn archived_block_ids(operations: &[PushOperation]) -> BTreeSet { .collect() } +fn deferred_archive_operation_indices( + operations: &[PushOperation], + block_parents: &BlockParentIndex, +) -> Vec { + let mut indices = operations + .iter() + .enumerate() + .filter_map(|(index, operation)| match operation { + PushOperation::ArchiveBlock { block_id } => { + Some((index, block_depth(block_id, block_parents), 0usize)) + } + PushOperation::ArchiveEntity { entity_id } => { + Some((index, block_depth(entity_id, block_parents), 1usize)) + } + _ => None, + }) + .collect::>(); + indices.sort_by_key(|(index, depth, kind)| (Reverse(*depth), *kind, *index)); + indices.into_iter().map(|(index, _, _)| index).collect() +} + +fn block_depth(block_id: &RemoteId, block_parents: &BlockParentIndex) -> usize { + let mut depth = 0; + let mut current = block_id; + let mut seen = BTreeSet::new(); + while seen.insert(current.clone()) { + let Some(parent) = block_parents.direct_parents.get(current) else { + break; + }; + depth += 1; + current = parent; + } + depth +} + fn database_create_parent_ids(operations: &[PushOperation]) -> BTreeSet { operations .iter() @@ -4244,6 +4222,37 @@ mod tests { use super::*; use crate::dto::{LinkDto, TextRichTextDto}; + #[test] + fn deferred_archive_indices_order_descendants_before_ancestors_and_entities() { + let operations = vec![ + PushOperation::ArchiveEntity { + entity_id: RemoteId::new("page-1"), + }, + PushOperation::ArchiveBlock { + block_id: RemoteId::new("parent-1"), + }, + PushOperation::UpdateBlock { + block_id: RemoteId::new("child-1"), + content: "Updated child.".to_string(), + }, + PushOperation::ArchiveBlock { + block_id: RemoteId::new("child-1"), + }, + ]; + let block_parents = BlockParentIndex { + direct_parents: BTreeMap::from([ + (RemoteId::new("parent-1"), RemoteId::new("page-1")), + (RemoteId::new("child-1"), RemoteId::new("parent-1")), + ]), + containing_pages: BTreeMap::new(), + }; + + assert_eq!( + deferred_archive_operation_indices(&operations, &block_parents), + vec![3, 1, 0] + ); + } + #[test] fn rich_text_payload_splits_text_content_at_notion_limit() { let content = "a".repeat(NOTION_RICH_TEXT_CONTENT_LIMIT * 2 + 33); diff --git a/crates/locality-notion/tests/apply.rs b/crates/locality-notion/tests/apply.rs index 126c5572..5300a19d 100644 --- a/crates/locality-notion/tests/apply.rs +++ b/crates/locality-notion/tests/apply.rs @@ -978,6 +978,104 @@ fn apply_normalizes_append_after_nested_block_to_direct_child_ancestor() { ); } +#[test] +fn apply_defers_archives_until_child_updates_finish_and_archives_deepest_first() { + let mut parent = callout_block("parent-1", "Parent"); + parent.has_children = true; + let mut api = RecordingNotionApi::with_blocks("2026-06-10T00:00:00.000Z", vec![parent]); + api.children.insert( + ("parent-1".to_string(), None), + PaginatedListDto { + results: vec![paragraph_block("child-1", "Before.", false)], + next_cursor: None, + has_more: false, + }, + ); + let api = Arc::new(api); + let connector = NotionConnector::with_api(NotionConfig::default(), api.clone()); + let plan = PushPlan::new( + vec![RemoteId::new("page-1")], + vec![ + PushOperation::ArchiveEntity { + entity_id: RemoteId::new("page-1"), + }, + PushOperation::ArchiveBlock { + block_id: RemoteId::new("parent-1"), + }, + PushOperation::ArchiveBlock { + block_id: RemoteId::new("child-1"), + }, + PushOperation::UpdateBlock { + block_id: RemoteId::new("child-1"), + content: "After.".to_string(), + }, + ], + ); + let push_id = PushId("push-1".to_string()); + let operation_ids = operation_ids(&push_id, &plan); + let mount_id = MountId::new("notion-main"); + + let result = connector + .apply(ApplyPlanRequest { + push_id: &push_id, + mount_id: &mount_id, + plan: &plan, + operation_ids: &operation_ids, + remote_preconditions: &[], + local_root: None, + }) + .expect("apply"); + + assert_eq!( + result.effects, + vec![ + JournalApplyEffect::UpdatedBlock { + operation_id: operation_ids[3].clone(), + operation_index: 3, + block_id: RemoteId::new("child-1"), + }, + JournalApplyEffect::ArchivedBlock { + operation_id: operation_ids[2].clone(), + operation_index: 2, + block_id: RemoteId::new("child-1"), + }, + JournalApplyEffect::ArchivedBlock { + operation_id: operation_ids[1].clone(), + operation_index: 1, + block_id: RemoteId::new("parent-1"), + }, + JournalApplyEffect::ArchivedEntity { + operation_id: operation_ids[0].clone(), + operation_index: 0, + entity_id: RemoteId::new("page-1"), + }, + ] + ); + let writes = api.writes.lock().expect("writes"); + assert_eq!( + writes.as_slice(), + [ + WriteCall::Update { + block_id: "child-1".to_string(), + body: json!({ + "paragraph": { + "rich_text": rich_text_json("After."), + }, + }), + }, + WriteCall::Delete { + block_id: "child-1".to_string(), + }, + WriteCall::Delete { + block_id: "parent-1".to_string(), + }, + WriteCall::Delete { + block_id: "page-1".to_string(), + }, + ] + ); +} + #[test] fn apply_appends_tier_one_markdown_block_shapes() { let api = Arc::new(RecordingNotionApi::new("2026-06-10T00:00:00.000Z", false)); @@ -2271,7 +2369,7 @@ fn apply_preserves_link_to_database_when_native_move_is_planned_as_append_archiv } #[test] -fn apply_undo_restores_archived_block_by_appending_replacement() { +fn apply_undo_restores_archived_block_in_place() { let api = Arc::new(RecordingNotionApi::with_blocks( "2026-06-10T00:00:00.000Z", vec![paragraph_block("paragraph-1", "Old paragraph.", false)], @@ -2307,24 +2405,69 @@ fn apply_undo_restores_archived_block_by_appending_replacement() { let writes = api.writes.lock().expect("writes"); assert_eq!( *writes, - vec![WriteCall::Append { - block_id: "page-1".to_string(), - body: json!({ - "children": [{ - "object": "block", - "type": "paragraph", - "paragraph": { - "rich_text": rich_text_json("Old paragraph."), - }, - }], - "position": { - "type": "start", - }, - }), + vec![WriteCall::Update { + block_id: "paragraph-1".to_string(), + body: json!({ "in_trash": false }), }] ); } +#[test] +fn apply_undo_restores_nested_archived_blocks_in_place_ancestor_first() { + let api = Arc::new(RecordingNotionApi::with_blocks( + "2026-06-10T00:00:00.000Z", + vec![paragraph_block("survivor-1", "Survivor.", false)], + )); + let connector = NotionConnector::with_api(NotionConfig::default(), api.clone()); + let push_id = PushId("push-nested-archive".to_string()); + let mount_id = MountId::new("notion-main"); + let restore = |block_id: &str, parent_id: &str| UndoOperation::RestoreArchivedBlock { + block_id: RemoteId::new(block_id), + parent_id: RemoteId::new(parent_id), + after: Some(RemoteId::new("intentionally-ignored-anchor")), + content: format!("Restored {block_id}."), + native_kind: Some("paragraph".to_string()), + }; + let undo_plan = UndoPlan { + target_push_id: push_id.clone(), + mount_id: mount_id.clone(), + affected_entities: vec![RemoteId::new("page-1")], + operations: vec![ + restore("parent-1", "page-1"), + restore("child-1", "parent-1"), + restore("grandchild-1", "child-1"), + ], + unsupported: vec![], + status: UndoPlanStatus::Complete, + }; + + connector + .apply_undo(ApplyUndoRequest { + target_push_id: &push_id, + mount_id: &mount_id, + plan: &undo_plan, + }) + .expect("restore nested archived blocks"); + + assert_eq!( + api.writes.lock().expect("writes").as_slice(), + [ + WriteCall::Update { + block_id: "parent-1".to_string(), + body: json!({ "in_trash": false }), + }, + WriteCall::Update { + block_id: "child-1".to_string(), + body: json!({ "in_trash": false }), + }, + WriteCall::Update { + block_id: "grandchild-1".to_string(), + body: json!({ "in_trash": false }), + }, + ] + ); +} + #[test] fn apply_undo_restores_archived_paragraph_link_labeled_like_link_to_page_as_paragraph() { let target_id = "11111111111111111111111111111111"; @@ -2361,32 +2504,15 @@ fn apply_undo_restores_archived_paragraph_link_labeled_like_link_to_page_as_para let writes = api.writes.lock().expect("writes"); assert_eq!( *writes, - vec![WriteCall::Append { - block_id: "page-1".to_string(), - body: json!({ - "children": [{ - "object": "block", - "type": "paragraph", - "paragraph": { - "rich_text": [{ - "type": "mention", - "mention": { - "type": "page", - "page": { "id": target_id }, - }, - }], - }, - }], - "position": { - "type": "start", - }, - }), + vec![WriteCall::Update { + block_id: "paragraph-1".to_string(), + body: json!({ "in_trash": false }), }] ); } #[test] -fn apply_undo_restores_archived_directive_by_appending_replacement() { +fn apply_undo_restores_archived_directive_in_place() { let api = Arc::new(RecordingNotionApi::with_blocks( "2026-06-10T00:00:00.000Z", vec![paragraph_block("paragraph-1", "Anchor.", false)], @@ -2420,16 +2546,10 @@ fn apply_undo_restores_archived_directive_by_appending_replacement() { let writes = api.writes.lock().expect("writes"); assert_eq!( *writes, - vec![append_call( - "paragraph-1", - json!({ - "object": "block", - "type": "table_of_contents", - "table_of_contents": { - "color": "default", - }, - }), - )] + vec![WriteCall::Update { + block_id: "toc-1".to_string(), + body: json!({ "in_trash": false }), + }] ); } diff --git a/crates/locality-store/src/sqlite.rs b/crates/locality-store/src/sqlite.rs index 3442daee..91dd193c 100644 --- a/crates/locality-store/src/sqlite.rs +++ b/crates/locality-store/src/sqlite.rs @@ -58,7 +58,7 @@ use crate::repository::{ const DB_FILE: &str = "state.sqlite3"; const SCHEMA_VERSION: i64 = 20; const ENTITY_SEARCH_COMPONENT_VERSION: i64 = 2; -const JOURNALS_COMPONENT_VERSION: i64 = 3; +const JOURNALS_COMPONENT_VERSION: i64 = 4; const LINUX_FUSE_PROJECTION_LAYOUT_VERSION: i64 = 2; const WINDOWS_CLOUD_FILES_PROJECTION_LAYOUT_VERSION: i64 = 2; const RETIRED_NOTION_WORKSPACE_ROOTS_COMPONENT_ID: &str = "projection:notion_workspace_roots"; @@ -2747,7 +2747,7 @@ fn initialize_schema(connection: &Connection) -> StoreResult<()> { ensure_state_components_allow_schema_migration(connection, user_version)?; migrate_linux_fuse_projection_layout_to_v2(connection, false)?; migrate_windows_cloud_files_projection_layout_to_v2(connection, false)?; - migrate_journals_component_to_v3(connection)?; + migrate_journals_component_to_v4(connection)?; migrate_virtual_mutations_component_to_v3(connection)?; migrate_entity_search_component_to_v2(connection)?; return Ok(()); @@ -3369,7 +3369,7 @@ fn state_component_issue_allows_schema_migration( component_id, found, current: JOURNALS_COMPONENT_VERSION, - } if component_id == "durable:journals" && matches!(*found, 1 | 2) + } if component_id == "durable:journals" && matches!(*found, 1..=3) ) || matches!( issue, StateCompatibilityIssue::OlderComponent { @@ -4137,7 +4137,7 @@ fn migrate_virtual_mutations_component_to_v3(connection: &Connection) -> StoreRe migrate_state_component_to_current(connection, "durable:virtual_mutations") } -fn migrate_journals_component_to_v3(connection: &Connection) -> StoreResult<()> { +fn migrate_journals_component_to_v4(connection: &Connection) -> StoreResult<()> { migrate_state_component_to_current(connection, "durable:journals") } diff --git a/crates/locality-store/tests/sqlite.rs b/crates/locality-store/tests/sqlite.rs index 321ce328..e0b63189 100644 --- a/crates/locality-store/tests/sqlite.rs +++ b/crates/locality-store/tests/sqlite.rs @@ -116,8 +116,8 @@ fn sqlite_store_seeds_state_compatibility_components() { ( "durable:journals".to_string(), "durable_json".to_string(), - 3, - 3, + 4, + 4, 1, 0 ), @@ -809,132 +809,83 @@ fn sqlite_store_reports_virtual_mutations_v4_without_mutating_state() { } #[test] -fn sqlite_store_migrates_journals_component_v2_to_v3_without_rewriting_rows() { - let fixture = SqliteFixture::new(); - let mut store = fixture.open(); - store - .save_mount(fixture.mount_config()) - .expect("save mount"); - let connection = Connection::open(&store.db_path).expect("raw connection"); - insert_released_v2_journal(&connection); - connection - .execute( - "UPDATE state_components - SET version = 2, min_reader_version = 1 - WHERE component_id = 'durable:journals'", - [], - ) - .expect("mark journal component v2"); - let before_row = journal_json_row(&connection, "push-v2"); - let before_user_version: i64 = connection - .query_row("PRAGMA user_version", [], |row| row.get(0)) - .expect("user version"); - drop(connection); - drop(store); - - let before = - SqliteStateStore::inspect_compatibility(fixture.state_root.clone()).expect("inspect v2"); - assert_eq!(before.status, StateCompatibilityStatus::Migratable); - assert_eq!( - before.issues, - vec![StateCompatibilityIssue::OlderComponent { - component_id: "durable:journals".to_string(), - found: 2, - current: 3, - }] - ); - - let reopened = fixture.open(); - let connection = Connection::open(&reopened.db_path).expect("raw reopened connection"); - let (version, min_reader_version): (i64, i64) = connection - .query_row( - "SELECT version, min_reader_version - FROM state_components - WHERE component_id = 'durable:journals'", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .expect("journal component metadata"); - let after_user_version: i64 = connection - .query_row("PRAGMA user_version", [], |row| row.get(0)) - .expect("user version"); - let after_row = journal_json_row(&connection, "push-v2"); - let loaded = reopened - .get_journal(&PushId("push-v2".to_string())) - .expect("read migrated journal") - .expect("journal"); - - assert_eq!((version, min_reader_version), (3, 3)); - assert_eq!(before_user_version, 20); - assert_eq!(after_user_version, before_user_version); - assert_eq!(after_row, before_row); - assert_eq!( - loaded, - journal_entry("push-v2", JournalStatus::Reconciled) - .with_apply_effects(apply_effects("push-v2")) - ); -} - -#[test] -fn sqlite_store_migrates_journals_component_v1_to_v3_at_current_schema() { - let fixture = SqliteFixture::new(); - let store = fixture.open(); - let connection = Connection::open(&store.db_path).expect("raw connection"); - let before_user_version: i64 = connection - .query_row("PRAGMA user_version", [], |row| row.get(0)) - .expect("user version"); - connection - .execute( - "UPDATE state_components - SET version = 1, min_reader_version = 1 - WHERE component_id = 'durable:journals'", - [], - ) - .expect("mark journal component v1"); - drop(connection); - drop(store); - - let before = - SqliteStateStore::inspect_compatibility(fixture.state_root.clone()).expect("inspect v1"); - assert_eq!(before.status, StateCompatibilityStatus::Migratable); - assert_eq!( - before.issues, - vec![StateCompatibilityIssue::OlderComponent { - component_id: "durable:journals".to_string(), - found: 1, - current: 3, - }] - ); +fn sqlite_store_migrates_journals_component_v1_v2_v3_to_v4_without_rewriting_rows() { + for old_version in [1, 2, 3] { + let fixture = SqliteFixture::new(); + let mut store = fixture.open(); + store + .save_mount(fixture.mount_config()) + .expect("save mount"); + let connection = Connection::open(&store.db_path).expect("raw connection"); + insert_released_v2_journal(&connection); + connection + .execute( + "UPDATE state_components + SET version = ?1, min_reader_version = 1 + WHERE component_id = 'durable:journals'", + [old_version], + ) + .expect("mark old journal component"); + let before_row = journal_json_row(&connection, "push-v2"); + let before_user_version: i64 = connection + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .expect("user version"); + drop(connection); + drop(store); - let reopened = fixture.open(); - let connection = Connection::open(&reopened.db_path).expect("raw reopened connection"); - let component: (i64, i64) = connection - .query_row( - "SELECT version, min_reader_version - FROM state_components - WHERE component_id = 'durable:journals'", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .expect("journal component metadata"); - let after_user_version: i64 = connection - .query_row("PRAGMA user_version", [], |row| row.get(0)) - .expect("user version"); + let before = SqliteStateStore::inspect_compatibility(fixture.state_root.clone()) + .expect("inspect old journals component"); + assert_eq!(before.status, StateCompatibilityStatus::Migratable); + assert_eq!( + before.issues, + vec![StateCompatibilityIssue::OlderComponent { + component_id: "durable:journals".to_string(), + found: old_version, + current: 4, + }] + ); - assert_eq!(component, (3, 3)); - assert_eq!(before_user_version, 20); - assert_eq!(after_user_version, before_user_version); + let reopened = fixture.open(); + let connection = Connection::open(&reopened.db_path).expect("raw reopened connection"); + let component: (i64, i64) = connection + .query_row( + "SELECT version, min_reader_version + FROM state_components + WHERE component_id = 'durable:journals'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("journal component metadata"); + let after_user_version: i64 = connection + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .expect("user version"); + let after_row = journal_json_row(&connection, "push-v2"); + let loaded = reopened + .get_journal(&PushId("push-v2".to_string())) + .expect("read migrated journal") + .expect("journal"); + + assert_eq!(component, (4, 4)); + assert_eq!(before_user_version, 20); + assert_eq!(after_user_version, before_user_version); + assert_eq!(after_row, before_row); + assert_eq!( + loaded, + journal_entry("push-v2", JournalStatus::Reconciled) + .with_apply_effects(apply_effects("push-v2")) + ); + } } #[test] -fn sqlite_store_reports_journals_component_v4_as_needs_update() { +fn sqlite_store_reports_journals_component_v5_as_needs_update() { let fixture = SqliteFixture::new(); let store = fixture.open(); let connection = Connection::open(&store.db_path).expect("raw connection"); connection .execute( "UPDATE state_components - SET version = 4, min_reader_version = 4 + SET version = 5, min_reader_version = 5 WHERE component_id = 'durable:journals'", [], ) @@ -943,17 +894,17 @@ fn sqlite_store_reports_journals_component_v4_as_needs_update() { drop(store); let report = - SqliteStateStore::inspect_compatibility(fixture.state_root.clone()).expect("inspect v4"); + SqliteStateStore::inspect_compatibility(fixture.state_root.clone()).expect("inspect v5"); assert_eq!(report.status, StateCompatibilityStatus::NeedsUpdate); assert_eq!( report.issues, vec![StateCompatibilityIssue::NewerComponent { component_id: "durable:journals".to_string(), - found: 4, - supported: 3, + found: 5, + supported: 4, }] ); - let error = SqliteStateStore::open(fixture.state_root.clone()).expect_err("v4 open blocked"); + let error = SqliteStateStore::open(fixture.state_root.clone()).expect_err("v5 open blocked"); assert!(matches!(error, StoreError::StateCompatibility(_))); } @@ -1727,7 +1678,7 @@ fn sqlite_store_blocks_newer_component_versions() { } #[test] -fn sqlite_store_migrates_v2_journal_component_to_v3() { +fn sqlite_store_migrates_v2_journal_component_to_v4() { let fixture = SqliteFixture::new(); let store = fixture.open(); let connection = Connection::open(&store.db_path).expect("raw connection"); @@ -1754,7 +1705,7 @@ fn sqlite_store_migrates_v2_journal_component_to_v3() { ) .expect("journal component versions"); - assert_eq!((version, min_reader_version), (3, 3)); + assert_eq!((version, min_reader_version), (4, 4)); } #[test] @@ -1781,7 +1732,7 @@ fn sqlite_store_blocks_components_that_require_newer_readers() { vec![StateCompatibilityIssue::ComponentRequiresNewerReader { component_id: "durable:journals".to_string(), min_reader_version: 999, - supported: 3, + supported: 4, }] ); @@ -1813,7 +1764,7 @@ fn sqlite_store_blocks_newer_journal_component_versions() { vec![StateCompatibilityIssue::NewerComponent { component_id: "durable:journals".to_string(), found: 999, - supported: 3, + supported: 4, }] ); @@ -3479,7 +3430,7 @@ fn journal_reader_remaps_mixed_legacy_operation_indexes_for_undo() { } #[test] -fn sqlite_store_round_trips_v3_entity_body_journal_rows() { +fn sqlite_store_round_trips_entity_body_journal_rows_at_v4() { let fixture = SqliteFixture::new(); let mut store = fixture.open(); store @@ -3522,7 +3473,7 @@ fn sqlite_store_round_trips_v3_entity_body_journal_rows() { .expect("journal component metadata"); assert_eq!(loaded, entry); - assert_eq!(component, (3, 3)); + assert_eq!(component, (4, 4)); } #[test] @@ -3734,7 +3685,7 @@ fn sqlite_store_migrates_v16_journals_with_empty_edit_metadata() { .expect("journal"); assert_eq!(user_version, 20); - assert_eq!(journals_component_version, 3); + assert_eq!(journals_component_version, 4); assert_eq!( metadata_json, serde_json::to_string(&JournalMetadata::default()).expect("default metadata json") @@ -3819,7 +3770,7 @@ fn sqlite_store_migrates_v17_mounts_with_default_settings_json() { assert_eq!(user_version, 20); assert_eq!(settings_json, "{}"); assert_eq!(migration_count, 1); - assert_eq!(journal_component, (3, 3)); + assert_eq!(journal_component, (4, 4)); } fn query_state_components(connection: &Connection) -> Vec<(String, String, i64, i64, i64, i64)> { diff --git a/docs/cli.md b/docs/cli.md index 4e53f155..b46be83b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -968,7 +968,7 @@ hint for each entry that has a saved diff. - complete plans are handed to the connector reverse-apply hook only when the target is still the latest non-reverted journal for every affected, preimage, and entity-operation ID; - connector apply results must report every entity required for local reconciliation as changed. Missing IDs, invalid entity observations, dirty local projections, pending virtual mutations, and cache or visible-provider destination collisions fail closed without marking the journal reverted; - successful virtual-projection undo updates the daemon cache and entity index, then refreshes the old and new provider containers instead of replaying the remote undo as a local filesystem move or delete; -- Notion reverse apply supports block content and property restore, archiving journaled created blocks/entities, restoring archived entities when a complete identity preimage is available, and restoring archived block content by appending a replacement at the original position when the public API cannot unarchive the original block; +- Notion reverse apply supports block content and property restore, archiving journaled created blocks/entities, restoring archived entities when a complete identity preimage is available, and restoring archived blocks in place by clearing `in_trash` so their identity and hierarchy remain intact; - `applying` and `failed` entries return `undo_unsafe_journal_status` because partial remote effects may still be in flight or unknown. Undo plans are `complete`, `partial`, or `blocked`. Complete plans can include reverse operations for block updates, block moves, archived blocks, appended blocks with journaled created IDs, whole-entity body updates, property updates, entity moves, archived entities with complete identity preimages and expected archived postimages, and created entities with journaled created IDs. Whole-entity body reverse apply is connector-specific and remains unsupported by Notion's block API. diff --git a/docs/e2e-behavior-coverage.md b/docs/e2e-behavior-coverage.md index c275fcaf..eef99574 100644 --- a/docs/e2e-behavior-coverage.md +++ b/docs/e2e-behavior-coverage.md @@ -132,7 +132,7 @@ Coverage labels: | E2E-011 | Editing a local page marks it pending in status before push. | Covered live | Local status and architecture behavior tests; `crates/loc-cli/tests/e2e_push_workflow.rs::cli_status_and_info_missing_mount_credential_stay_local_without_leaking_secret_ref`. | Live coverage uses CLI status; local CLI-binary coverage verifies dirty status remains available from local state when the stored Notion credential is missing. Desktop/tray pending display remains manual. | | E2E-012 | Diff/review planning reports intended changes before applying remote writes, and destructive plans require explicit confirmation before remote writes. | Covered live | `mount_pull_mid_page_insert_push_and_status_clean`; `crates/loc-cli/tests/e2e_push_workflow.rs::large_archive_plan_requires_confirm_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::auto_save_safe_update_reconciles_and_destructive_update_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::cli_live_mode_toggles_file_auto_save_enrollment`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_cli_live_mode_enables_file_auto_save_without_immediate_push`; `crates/loc-cli/tests/e2e_push_workflow.rs::new_page_create_validation_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::database_row_create_locality_metadata_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::frontmatter_remote_id_mismatch_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::cli_diff_missing_mount_credential_plans_from_local_shadow`; push/diff tests. | Local e2e covers the daemon auto-save guardrail path, the real CLI binary path for enabling/disabling per-file Live Mode enrollment, and live scratch coverage verifies file Live Mode enablement does not immediately push local edits before the auto-save path reconciles them; page-create, database row-create, and frontmatter validation are covered before journal/apply; a CLI-binary workflow verifies `diff` can still plan from local shadow when the Notion credential is missing. Desktop review UI is not e2e automated. | | E2E-013 | Pushing a simple page edit writes to Notion, fetches back, and status returns clean. | Covered live | Local push workflow tests. | Covered. | -| E2E-014 | Supported rich block edits push and verify: paragraph, headings 1-4, bullet, number, todo, quote, callout, code, divider, equation, table, bookmark, embed, media URL/caption, block-type replacement, safe directive block moves, read-only `link_to_page`/database line moves, unsafe `link_to_page`/database edit/retarget blocking, unsafe `link_preview` edit/move/delete blocking, table trailing-row delete, unsafe table width/header-mode and row-identity blocking, and unsafe child-page link edit/move/delete blocking. | Covered live | Notion apply/render tests; `crates/loc-cli/tests/e2e_push_workflow.rs::live_block_type_replace_pushes_and_reconciles_notion`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_directive_block_move_pushes_and_reconciles_notion`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_link_to_page_line_move_preserves_notion_block_type`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_link_to_database_line_move_preserves_notion_block_type`; `crates/loc-cli/tests/e2e_push_workflow.rs::notion_link_to_page_label_edit_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_link_to_page_retarget_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_link_to_database_retarget_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_paragraph_notion_link_labeled_like_link_to_page_can_be_edited`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_rich_text_markdown_pushes_annotations_links_equations_and_mentions`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_rich_text_color_annotations_survive_adjacent_markdown_edit`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_block_color_survives_mounted_markdown_text_edit`; `crates/loc-cli/tests/e2e_push_workflow.rs::notion_link_preview_edit_move_delete_block_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_table_trailing_row_delete_pushes_and_reconciles`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_table_width_change_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::notion_table_header_mode_change_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_table_middle_row_delete_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::notion_child_page_link_label_edit_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_child_page_link_move_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_child_page_link_delete_blocks_before_journaled_apply`. | Covered for representative edits, replacement, a childless directive move, rendered `link_to_page` and database link moves, local `link_to_page` edit and live `link_to_page`/database retarget guardrails, ordinary paragraph Notion links whose labels look like rendered `link_to_page` blocks, mounted Markdown rich-text span pushes plus non-default rich-text and block color preservation verified against remote Notion JSON, local `link_preview` edit/move/delete guardrails, supported table trailing-row delete, live table width and middle-row-delete guardrails, local table header-mode guardrail, and local child-page edit plus live child-page move/delete guardrails; not every layout option. | +| E2E-014 | Supported rich block edits push and verify: paragraph, headings 1-4, bullet, number, todo, quote, callout, code, divider, equation, table, bookmark, embed, media URL/caption, block-type replacement, safe directive block moves, read-only `link_to_page`/database line moves, unsafe `link_to_page`/database edit/retarget blocking, unsafe `link_preview` edit/move/delete blocking, table trailing-row delete, unsafe table width/header-mode and row-identity blocking, and unsafe child-page link edit/move/delete blocking. | Covered live | Notion apply/render tests; `crates/loc-cli/tests/e2e_push_workflow.rs::nested_archive_push_and_undo_preserve_hierarchy_and_order`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_block_type_replace_pushes_and_reconciles_notion`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_directive_block_move_pushes_and_reconciles_notion`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_link_to_page_line_move_preserves_notion_block_type`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_link_to_database_line_move_preserves_notion_block_type`; `crates/loc-cli/tests/e2e_push_workflow.rs::notion_link_to_page_label_edit_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_link_to_page_retarget_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_link_to_database_retarget_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_paragraph_notion_link_labeled_like_link_to_page_can_be_edited`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_rich_text_markdown_pushes_annotations_links_equations_and_mentions`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_rich_text_color_survives_adjacent_markdown_edit`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_block_color_survives_mounted_markdown_text_edit`; `crates/loc-cli/tests/e2e_push_workflow.rs::notion_link_preview_edit_move_delete_block_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_table_trailing_row_delete_pushes_and_reconciles`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_table_width_change_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::notion_table_header_mode_change_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_table_middle_row_delete_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::notion_child_page_link_label_edit_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_child_page_link_move_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_child_page_link_delete_blocks_before_journaled_apply`. | Covered for representative edits, replacement, a childless directive move, descendant-first nested block archival after non-archive writes, in-place ancestor-first undo that preserves the original nested hierarchy, rendered `link_to_page` and database link moves, local `link_to_page` edit and live `link_to_page`/database retarget guardrails, ordinary paragraph Notion links whose labels look like rendered `link_to_page` blocks, mounted Markdown rich-text span pushes plus non-default rich-text and block color preservation verified against remote Notion JSON, local `link_preview` edit/move/delete guardrails, supported table trailing-row delete, live table width and middle-row-delete guardrails, local table header-mode guardrail, and local child-page edit plus live child-page move/delete guardrails; not every layout option. | | E2E-015 | Typed rich text round-trips for links, annotations, inline equations, page/database/date/user mentions. | Covered live | Notion renderer/apply tests; `crates/loc-cli/tests/e2e_push_workflow.rs::live_rich_text_markdown_pushes_annotations_links_equations_and_mentions`; `crates/loc-cli/tests/e2e_push_workflow.rs::live_rich_text_color_annotations_survive_adjacent_markdown_edit`. | Live mounted workflow now verifies Markdown-authored bold, italic, strikethrough, underline, code, external links, inline equations, date mentions, user mentions, page mentions, and database mentions against the remote Notion rich-text JSON, and verifies an unchanged non-default Notion color annotation survives adjacent Markdown edits. Page/database/user mention writes require explicit IDs. | | E2E-016 | Database directories expose `_schema.yaml` and row Markdown files. | Covered live | Pull and schema tests; `crates/loc-cli/tests/e2e_push_workflow.rs::database_row_missing_schema_diff_blocks_and_push_repairs_before_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::database_row_create_missing_schema_push_repairs_before_plan`. | Covered through plain-file live mounted workflow, plus local daemon-backed push coverage that repairs a missing `_schema.yaml` from the connector before existing row property edits and draft row-create planning. Real File Provider schema-cache repair remains covered below the OS adapter only. | | E2E-017 | Database row property validation rejects unknown properties, invalid options, invalid scalar shapes, invalid ID-shaped values, and read-only properties before writing. | Covered live | Schema validation tests; `crates/loc-cli/tests/e2e_push_workflow.rs::live_database_row_invalid_select_option_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::database_row_unknown_property_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::database_row_scalar_property_shapes_block_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::database_row_read_only_property_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::database_row_people_relation_id_shapes_block_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::database_row_create_read_only_property_blocks_before_journaled_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::database_row_missing_schema_diff_blocks_and_push_repairs_before_apply`; `crates/loc-cli/tests/e2e_push_workflow.rs::database_row_create_missing_schema_push_repairs_before_plan`. | Covered through connector-level validation, a mounted live workflow that proves invalid options stop before journal/apply and leave Notion unchanged, and local mounted workflows that prove unknown/out-of-schema properties, invalid number/date/URL/file shapes, read-only/unsupported property edits, invalid people/relation ID shapes, missing schema diff behavior, schema repair before daemon-backed existing row pushes and draft row-create planning, and read-only generated properties on row create stop before journal/apply or connector writes. Desktop UI path remains separate. | @@ -183,6 +183,7 @@ Coverage labels: | `crates/loc-cli/tests/e2e_push_workflow.rs::pull_materializes_and_repairs_downloaded_media_cache` | Local mounted workflow, media cache | Serves a downloadable image from a local HTTP fixture, pulls it through the Notion connector path, verifies Markdown uses a `.loc/media` href, verifies image bytes and `manifest.json` are written, deletes the local image and pulls again to verify repair, then removes the image remotely and pulls again to verify the stale local file and manifest entry are pruned. Covers the local materialization, missing-file repair, and stale-cache pruning side of E2E-010 without relying on external media hosts. | | `crates/loc-cli/tests/e2e_push_workflow.rs::oversized_local_media_append_fails_without_connector_apply_and_records_failed_journal` | Local mounted workflow, media guardrail | Appends a local `.loc/media` PDF over the 20 MB single-upload cap, verifies `diff` plans the media block create, then verifies daemon-backed `push` fails with an unsupported-media message, records a failed journal, and does not call the connector upload/append path. Covers the local oversized-upload guardrail for E2E-010 and E2E-014. | | `crates/loc-cli/tests/e2e_push_workflow.rs::large_archive_plan_requires_confirm_before_journaled_apply` | Local mounted workflow | Pulls a page with 12 synced paragraph blocks, removes the body locally, verifies diff reports a `confirm_dangerous_plan`, verifies unconfirmed push creates no journal and makes no connector write calls, then confirms the dangerous push and verifies 12 archive calls plus clean reconciliation. Covers destructive-plan review for E2E-012 and archive guardrails for E2E-014. | +| `crates/loc-cli/tests/e2e_push_workflow.rs::nested_archive_push_and_undo_preserve_hierarchy_and_order` | Local mounted workflow | Pulls a three-level Notion block tree, updates a retained paragraph while deleting the nested subtree, verifies diff preserves parent-first shadow order, pushes through journal/apply/reconcile, asserts update-first and grandchild-to-parent archival, then undoes by restoring the original IDs parent-to-grandchild and verifies the remote hierarchy plus clean local status. Covers nested archive and undo ordering for E2E-014. | | `crates/loc-cli/tests/e2e_push_workflow.rs::auto_save_safe_update_reconciles_and_destructive_update_blocks_before_journaled_apply` | Local daemon auto-save workflow | Enrolls a mounted page for auto-save, verifies a safe paragraph edit reconciles through the daemon-owned auto-save push path, then removes the synced body and verifies the destructive auto-save attempt returns `auto_save_blocked` without creating another journal entry or calling the connector. Covers Live Mode's conservative local-save side of E2E-012. | | `crates/loc-cli/tests/e2e_push_workflow.rs::cli_live_mode_toggles_file_auto_save_enrollment` | Local CLI binary workflow | Seeds an isolated mounted Notion page, runs the real `loc live-mode on/status/off --json` command against that file, and verifies the durable auto-save enrollment is enabled, reset to active, reports status, then disables without touching connector credentials. Covers the CLI control surface for file Live Mode enrollment. | | `crates/loc-cli/tests/e2e_push_workflow.rs::live_cli_live_mode_enables_file_auto_save_without_immediate_push` | Live CLI binary workflow, plain files | Creates a scratch Notion page, mounts and pulls it with the real `loc` binary and stored credential path, edits `page.md`, runs `loc live-mode on --json`, verifies the durable file enrollment is active without mutating Notion, executes the auto-save push path, verifies Notion receives the marker edit, then disables file Live Mode. Covers the live scratch product path for file Live Mode enrollment and conservative no-immediate-push behavior. | diff --git a/docs/locality-core.md b/docs/locality-core.md index a92d808c..1370c513 100644 --- a/docs/locality-core.md +++ b/docs/locality-core.md @@ -125,6 +125,16 @@ block next to the old block, journal the created block ID, then archive the old block. Auto-save treats replacements as review-required because the old remote block ID is removed. +The Notion connector defers standalone block and entity archives until all +other writes finish, then archives nested descendants before their ancestors. +This keeps later child updates and deletes valid when one approved plan removes +a parent block or page and also changes or removes content beneath it. Notion +records this complete archive-last execution shape in its apply effects so undo +can reverse the actual order and restore ancestors before descendants. Notion +restores archived blocks in place by clearing `in_trash`, preserving their IDs, +parents, sibling positions, children, and native block types. Other connectors +and legacy or incomplete journals continue to use reverse plan order. + `CreateEntity` is the connector-neutral shape for local file creation. For the filesystem projection it carries the parent remote ID, user title, initial property values, initial body, and the source path that produced the create request. Connectors assign the real remote ID and return a `CreatedEntity` apply effect; reconciliation then reads the created remote entity back, materializes the canonical projected path, saves the shadow, and lets undo archive the created entity by ID. ## Undo Contract @@ -135,9 +145,10 @@ Journal entries now include shadow preimages for affected entities. The undo pla - block replacements reverse to archiving the replacement block and restoring the original block from the preimage when the replacement ID was journaled; - block moves reverse to the previous sibling position; -- archived blocks reverse to a restore operation with original content, position, - and native block kind when the preimage carries it, so connectors can avoid - restoring Markdown lookalikes as the wrong native block type; +- archived blocks reverse to a restore operation with original content, + position, and native block kind when the preimage carries it. Connectors may + use that reconstruction payload when necessary; Notion restores the original + block identity in place instead; - appends reverse to archiving the created block when apply journaled the created block ID; - whole-entity body updates carry the pushed body as expected-current state and restore `shadow.rendered_body`; diff --git a/docs/locality-store.md b/docs/locality-store.md index d755e46e..4c8896d2 100644 --- a/docs/locality-store.md +++ b/docs/locality-store.md @@ -188,10 +188,12 @@ Shadow blocks, journal plans, journal preimages, and journal apply effects are J reset. - If a new writer produces state that older readers must not open, raise that component's `min_reader_version` so old binaries return `NeedsUpdate`. -- `durable:journals` version 3 adds whole-entity body operations and complete - entity reverse payloads. Its v2-to-v3 migration updates component metadata - only, leaves `PRAGMA user_version` and existing journal JSON rows unchanged, - and raises the minimum reader version to 3. +- `durable:journals` version 4 makes archive apply-effect order authoritative + for nested undo and requires readers that restore archived Notion blocks in + place. Its v1/v2/v3-to-v4 migration updates component metadata only, leaves + `PRAGMA user_version` and existing journal JSON rows unchanged, and raises + the minimum reader version to 4. Version 3 introduced whole-entity body + operations and complete entity reverse payloads. - Unknown required components block older binaries. Unknown non-required rebuildable components are ignored by older binaries.