Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/db/schema.surql
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ DEFINE INDEX IF NOT EXISTS idx_node_title ON TABLE node COLUMNS title;
--
-- Core relationship_types:
-- - has_child: Document tree hierarchy (order in properties)
-- - mentions: @references and [[links]] (context, offset, root_id in properties)
-- - mentions: @references and [[links]] (context, offset in properties)
-- - member_of: Collection membership
-- ============================================================================

Expand Down
107 changes: 78 additions & 29 deletions packages/core/src/db/surreal_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2876,24 +2876,19 @@ where
///
/// Issue #788: Universal Relationship Architecture - mentions stored in relationship table.
/// Issue #813: Pure data layer - no event emission, returns relationship ID for service layer.
/// Issue #834: Removed root_id storage - roots computed dynamically via graph traversal.
///
/// # Arguments
///
/// * `source_id` - The ID of the node that contains the mention
/// * `target_id` - The ID of the node being mentioned
/// * `root_id` - The root node ID for context
///
/// # Returns
///
/// * `Ok(Some(id))` - Relationship ID if newly created
/// * `Ok(None)` - If mention already existed (idempotent)
/// * `Err` - Database error
pub async fn create_mention(
&self,
source_id: &str,
target_id: &str,
root_id: &str,
) -> Result<Option<String>> {
pub async fn create_mention(&self, source_id: &str, target_id: &str) -> Result<Option<String>> {
let source_thing = surrealdb::sql::Thing::from(("node".to_string(), source_id.to_string()));
let target_thing = surrealdb::sql::Thing::from(("node".to_string(), target_id.to_string()));

Expand All @@ -2913,21 +2908,19 @@ where

// Only create mention if it doesn't exist
if existing_mention_ids.is_empty() {
// Issue #788: Universal Relationship Architecture - use CONTENT for properties
let query = format!(
r#"RELATE $source->relationship->$target CONTENT {{
// Issue #834: Simplified mention relationship - no properties.root_id
// Root/container is computed dynamically via graph traversal in get_mentioning_containers
let query = r#"RELATE $source->relationship->$target CONTENT {
relationship_type: 'mentions',
properties: {{ root_id: '{}' }},
properties: {},
created_at: time::now(),
modified_at: time::now(),
version: 1
}} RETURN id;"#,
root_id.replace('\'', "''")
);
} RETURN id;"#;

let mut response = self
.db
.query(&query)
.query(query)
.bind(("source", source_thing))
.bind(("target", target_thing))
.await
Expand Down Expand Up @@ -3080,45 +3073,101 @@ where
}

pub async fn get_mentioning_containers(&self, node_id: &str) -> Result<Vec<Node>> {
// Issue #788: Universal Relationship Architecture - query relationship table for mentions
// Issue #834: Compute roots dynamically via graph traversal instead of reading properties.root_id
// This eliminates denormalized/cached data that could become stale.
let target_thing = Thing::from(("node".to_string(), node_id.to_string()));
let query = "SELECT properties.root_id AS root_id FROM relationship WHERE out = $target AND relationship_type = 'mentions';";

// Step 1: Get all source nodes that mention the target
let query =
"SELECT in FROM relationship WHERE out = $target AND relationship_type = 'mentions';";

let mut response = self
.db
.query(query)
.bind(("target", target_thing))
.await
.context("Failed to get mentioning roots")?;
.context("Failed to get mentioning sources")?;

// Parse the response - each row has a root_id field from properties
#[derive(Debug, Deserialize)]
struct MentionRow {
root_id: Option<String>,
#[serde(rename = "in")]
source: Thing,
}

let results: Vec<MentionRow> = response
.take(0)
.context("Failed to extract root IDs from response")?;
.context("Failed to extract source IDs from response")?;

// Extract source IDs as strings
let source_ids: Vec<String> = results
.into_iter()
.filter_map(|r| {
if let Id::String(id_str) = r.source.id {
Some(id_str)
} else {
None
}
})
.collect();

// Collect root IDs
let mut root_ids: Vec<String> = results.into_iter().filter_map(|r| r.root_id).collect();
// Step 2: For each source, find its container (root or task)
// Tasks are their own containers; other nodes traverse up to find root
// Performance: O(N * depth) where N = mention count, depth = tree depth
// Typical case: N < 10, depth < 5 - acceptable for knowledge management workloads
let mut container_ids: Vec<String> = Vec::new();
for source_id in source_ids {
let container_id = self.get_container_for_mention(&source_id).await?;
container_ids.push(container_id);
}

// Deduplicate root IDs
root_ids.sort();
root_ids.dedup();
// Deduplicate container IDs
container_ids.sort();
container_ids.dedup();

// Fetch full node records
// Step 3: Fetch full node records
let mut nodes = Vec::new();
for root_id in root_ids {
if let Some(node) = self.get_node(&root_id).await? {
for container_id in container_ids {
if let Some(node) = self.get_node(&container_id).await? {
nodes.push(node);
}
}

Ok(nodes)
}

/// Find the container for a mentioning node
///
/// Tasks are treated as their own containers (they're self-contained items).
/// For all other nodes, traverse UP via has_child relationships to find the root.
///
/// # Issue #834
///
/// This replaces reading `properties.root_id` from the mention relationship,
/// ensuring container resolution is always accurate even if nodes have moved.
async fn get_container_for_mention(&self, source_id: &str) -> Result<String> {
// First, check if the source is a task (tasks are their own containers)
if let Some(source_node) = self.get_node(source_id).await? {
if source_node.node_type == "task" {
return Ok(source_id.to_string());
}
}

// For non-task nodes, traverse up to find root
let mut current_id = source_id.to_string();
loop {
let parent = self.get_parent(&current_id).await?;
match parent {
Some(parent_node) => {
current_id = parent_node.id;
}
None => {
// Found the root
return Ok(current_id);
}
}
}
}

pub async fn get_schema(&self, node_type: &str) -> Result<Option<Value>> {
// Schema nodes use simple IDs (just the node type name, e.g., "date")
// They're differentiated by node_type = "schema"
Expand Down
26 changes: 7 additions & 19 deletions packages/core/src/services/node_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1745,15 +1745,9 @@ where
}

// Prevent root-level self-references (child mentioning its own root)
let mentioning_node = self
.get_node(mentioning_node_id)
.await?
.ok_or_else(|| NodeServiceError::node_not_found(mentioning_node_id))?;

// Get root ID via edge traversal
// Get root ID via edge traversal for validation only
let root_id = self.get_root_id(mentioning_node_id).await?;

// Prevent root-level self-references (child mentioning its own root)
if root_id == mentioned_node_id {
return Err(NodeServiceError::ValidationFailed(
crate::models::ValidationError::InvalidParent(
Expand All @@ -1762,18 +1756,11 @@ where
));
}

// Get root ID with special handling for tasks
// Tasks are always treated as their own roots (exception rule)
let final_root_id = if mentioning_node.node_type == "task" {
mentioning_node_id
} else {
&root_id
};

// Issue #813: Store returns relationship ID, service emits event
// Issue #834: root_id no longer stored - computed dynamically via graph traversal
let relationship_id = self
.store
.create_mention(mentioning_node_id, mentioned_node_id, final_root_id)
.create_mention(mentioning_node_id, mentioned_node_id)
.await
.map_err(|e| NodeServiceError::query_failed(e.to_string()))?;

Expand All @@ -1785,7 +1772,7 @@ where
from_id: mentioning_node_id.to_string(),
to_id: mentioned_node_id.to_string(),
relationship_type: "mentions".to_string(),
properties: serde_json::json!({"root_id": final_root_id}),
properties: serde_json::json!({}),
},
source_client_id: self.client_id.clone(),
});
Expand Down Expand Up @@ -4747,9 +4734,10 @@ where
}

// Issue #813: Store returns relationship ID, service emits event
// Issue #834: root_id no longer stored - computed dynamically via graph traversal
let relationship_id = self
.store
.create_mention(source_id, target_id, source_id)
.create_mention(source_id, target_id)
.await
.map_err(|e| {
NodeServiceError::query_failed(format!("Failed to insert mention: {}", e))
Expand All @@ -4763,7 +4751,7 @@ where
from_id: source_id.to_string(),
to_id: target_id.to_string(),
relationship_type: "mentions".to_string(),
properties: serde_json::json!({"root_id": source_id}),
properties: serde_json::json!({}),
},
source_client_id: self.client_id.clone(),
});
Expand Down