chronogrid/crates/chronogrid_ffi/src/api/note_crdt.rs

295 lines
9.6 KiB
Rust

//! Note CRDT operations — create, read, mutate, and merge note documents.
//!
//! ## Data Flow
//!
//! ```text
//! Dart → note_crdt_create(id, ns, title, content) → Vec<u8>
//! Dart → db_save_crdt_doc(id, "note", bytes, ts)
//! Dart → db_load_crdt_doc(id) → Vec<u8>
//! Dart → note_crdt_read(bytes) → FfiNoteData
//! Dart → note_crdt_set_title(bytes, title) → Vec<u8>
//! ```
//!
//! 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<Vec<u8>> {
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<u8>) -> anyhow::Result<FfiNoteData> {
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<u8>, title: String) -> anyhow::Result<Vec<u8>> {
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<u8>,
content_markdown: String,
) -> anyhow::Result<Vec<u8>> {
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<u8>,
project_id: String,
) -> anyhow::Result<Vec<u8>> {
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<u8>) -> anyhow::Result<Vec<u8>> {
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<u8>,
namespace_id: String,
) -> anyhow::Result<Vec<u8>> {
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<u8>, doc_b: Vec<u8>) -> anyhow::Result<Vec<u8>> {
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());
}
}