//! Note CRDT operations — create, read, mutate, and merge note documents. //! //! ## Data Flow //! //! ```text //! Dart → note_crdt_create(id, ns, title, content) → Vec //! Dart → db_save_crdt_doc(id, "note", bytes, ts) //! Dart → db_load_crdt_doc(id) → Vec //! Dart → note_crdt_read(bytes) → FfiNoteData //! Dart → note_crdt_set_title(bytes, title) → Vec //! ``` //! //! Mutation functions require an active engine (HLC). Read and merge are //! stateless. use chronogrid_core::NoteCrdt; use super::state::with_engine_mut; use super::types::FfiNoteData; // ── Construction ───────────────────────────────────────────────────────────── /// Creates a new note CRDT document and returns its serialized bytes. /// /// **Requires active engine** (for HLC). pub fn note_crdt_create( id: String, namespace_id: String, title: String, content_markdown: String, ) -> anyhow::Result> { with_engine_mut(|engine| { let mut note = NoteCrdt::new(&id, &namespace_id, &title, &content_markdown, &mut engine.hlc) .map_err(|e| anyhow::anyhow!("note_crdt_create failed: {e}"))?; Ok(note.save()) }) } // ── Read ───────────────────────────────────────────────────────────────────── /// Deserializes a note CRDT document and returns a structured snapshot. /// /// **Stateless** — does not require an active engine. pub fn note_crdt_read(doc_bytes: Vec) -> anyhow::Result { let note = NoteCrdt::from_bytes(&doc_bytes) .map_err(|e| anyhow::anyhow!("note_crdt_read: failed to load doc: {e}"))?; Ok(FfiNoteData { id: note.entity_id()?.unwrap_or_default(), namespace_id: note.namespace_id()?.unwrap_or_default(), project_id: note.project_id()?, title: note.title()?.unwrap_or_default(), content_markdown: note.content_markdown()?.unwrap_or_default(), hlc_timestamp: note.hlc_timestamp()?.unwrap_or_default(), }) } // ── Field mutations ────────────────────────────────────────────────────────── /// Sets the note title. **Requires active engine.** pub fn note_crdt_set_title(doc_bytes: Vec, title: String) -> anyhow::Result> { with_engine_mut(|engine| { let mut note = NoteCrdt::from_bytes(&doc_bytes) .map_err(|e| anyhow::anyhow!("{e}"))?; note.set_title(&title, &mut engine.hlc) .map_err(|e| anyhow::anyhow!("{e}"))?; Ok(note.save()) }) } /// Sets the markdown content body. pub fn note_crdt_set_content( doc_bytes: Vec, content_markdown: String, ) -> anyhow::Result> { with_engine_mut(|engine| { let mut note = NoteCrdt::from_bytes(&doc_bytes) .map_err(|e| anyhow::anyhow!("{e}"))?; note.set_content_markdown(&content_markdown, &mut engine.hlc) .map_err(|e| anyhow::anyhow!("{e}"))?; Ok(note.save()) }) } /// Moves the note to a different project. pub fn note_crdt_set_project_id( doc_bytes: Vec, project_id: String, ) -> anyhow::Result> { with_engine_mut(|engine| { let mut note = NoteCrdt::from_bytes(&doc_bytes) .map_err(|e| anyhow::anyhow!("{e}"))?; note.set_project_id(&project_id, &mut engine.hlc) .map_err(|e| anyhow::anyhow!("{e}"))?; Ok(note.save()) }) } /// Removes the project association. pub fn note_crdt_clear_project_id(doc_bytes: Vec) -> anyhow::Result> { with_engine_mut(|engine| { let mut note = NoteCrdt::from_bytes(&doc_bytes) .map_err(|e| anyhow::anyhow!("{e}"))?; note.clear_project_id(&mut engine.hlc) .map_err(|e| anyhow::anyhow!("{e}"))?; Ok(note.save()) }) } /// Moves the note to a different namespace. pub fn note_crdt_set_namespace_id( doc_bytes: Vec, namespace_id: String, ) -> anyhow::Result> { with_engine_mut(|engine| { let mut note = NoteCrdt::from_bytes(&doc_bytes) .map_err(|e| anyhow::anyhow!("{e}"))?; note.set_namespace_id(&namespace_id, &mut engine.hlc) .map_err(|e| anyhow::anyhow!("{e}"))?; Ok(note.save()) }) } // ── Merge ──────────────────────────────────────────────────────────────────── /// Merges two note CRDT documents and returns the combined document bytes. /// /// **Stateless** — does not require an active engine. pub fn note_crdt_merge(doc_a: Vec, doc_b: Vec) -> anyhow::Result> { let mut note_a = NoteCrdt::from_bytes(&doc_a) .map_err(|e| anyhow::anyhow!("note_crdt_merge: failed to load doc_a: {e}"))?; let mut note_b = NoteCrdt::from_bytes(&doc_b) .map_err(|e| anyhow::anyhow!("note_crdt_merge: failed to load doc_b: {e}"))?; note_a .merge(&mut note_b) .map_err(|e| anyhow::anyhow!("note_crdt_merge: merge failed: {e}"))?; Ok(note_a.save()) } // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::super::engine::{close_engine, init_engine_in_memory}; use super::super::state::engine_test_guard; use super::*; const TEST_KEY: [u8; 32] = [0xAA; 32]; const NODE: &str = "test-node"; fn setup() { let _ = close_engine(); init_engine_in_memory(TEST_KEY.to_vec(), NODE.into()).unwrap(); } fn teardown() { let _ = close_engine(); } #[test] fn create_and_read_round_trip() { let _guard = engine_test_guard(); setup(); let bytes = note_crdt_create( "n-001".into(), "ns-1".into(), "My Note".into(), "# Hello World".into(), ) .unwrap(); let data = note_crdt_read(bytes).unwrap(); assert_eq!(data.id, "n-001"); assert_eq!(data.namespace_id, "ns-1"); assert_eq!(data.title, "My Note"); assert_eq!(data.content_markdown, "# Hello World"); assert!(!data.hlc_timestamp.is_empty()); teardown(); } #[test] fn set_all_fields_and_read() { let _guard = engine_test_guard(); setup(); let mut bytes = note_crdt_create( "n-002".into(), "ns-1".into(), "Title".into(), "Body".into(), ) .unwrap(); bytes = note_crdt_set_title(bytes, "New Title".into()).unwrap(); bytes = note_crdt_set_content(bytes, "## Updated\nBody text".into()).unwrap(); bytes = note_crdt_set_project_id(bytes, "p-001".into()).unwrap(); bytes = note_crdt_set_namespace_id(bytes, "ns-2".into()).unwrap(); let data = note_crdt_read(bytes).unwrap(); assert_eq!(data.title, "New Title"); assert_eq!(data.content_markdown, "## Updated\nBody text"); assert_eq!(data.project_id, Some("p-001".into())); assert_eq!(data.namespace_id, "ns-2"); teardown(); } #[test] fn clear_optional_fields() { let _guard = engine_test_guard(); setup(); let mut bytes = note_crdt_create( "n-003".into(), "ns-1".into(), "Title".into(), "Body".into(), ) .unwrap(); bytes = note_crdt_set_project_id(bytes, "p-1".into()).unwrap(); bytes = note_crdt_clear_project_id(bytes).unwrap(); let data = note_crdt_read(bytes).unwrap(); assert_eq!(data.project_id, None); teardown(); } #[test] fn merge_concurrent_edits() { let _guard = engine_test_guard(); setup(); let bytes_a = note_crdt_create( "n-004".into(), "ns-1".into(), "Note".into(), "Original".into(), ) .unwrap(); let bytes_b = note_crdt_set_content(bytes_a.clone(), "Peer B content".into()).unwrap(); let bytes_a = note_crdt_set_content(bytes_a, "Peer A content".into()).unwrap(); let merged = note_crdt_merge(bytes_a, bytes_b).unwrap(); let data = note_crdt_read(merged).unwrap(); assert!( data.content_markdown == "Peer A content" || data.content_markdown == "Peer B content", "merge must resolve to one of the concurrent writes, got: {}", data.content_markdown ); teardown(); } #[test] fn read_is_stateless() { let _guard = engine_test_guard(); let _ = close_engine(); init_engine_in_memory(TEST_KEY.to_vec(), NODE.into()).unwrap(); let bytes = note_crdt_create( "n-005".into(), "ns-1".into(), "Stateless".into(), "Content".into(), ) .unwrap(); close_engine().unwrap(); let data = note_crdt_read(bytes).unwrap(); assert_eq!(data.title, "Stateless"); } #[test] fn mutation_without_engine_fails() { let _guard = engine_test_guard(); let _ = close_engine(); let result = note_crdt_create("x".into(), "y".into(), "z".into(), "w".into()); assert!(result.is_err()); } }