295 lines
9.6 KiB
Rust
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());
|
|
}
|
|
}
|