diff --git a/packages/core/src/db/schema.surql b/packages/core/src/db/schema.surql index c9ee1b22b..ebed57dcd 100644 --- a/packages/core/src/db/schema.surql +++ b/packages/core/src/db/schema.surql @@ -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 -- ============================================================================ diff --git a/packages/core/src/db/surreal_store.rs b/packages/core/src/db/surreal_store.rs index adbd08e77..b1f29a019 100644 --- a/packages/core/src/db/surreal_store.rs +++ b/packages/core/src/db/surreal_store.rs @@ -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> { + pub async fn create_mention(&self, source_id: &str, target_id: &str) -> Result> { 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())); @@ -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 @@ -3080,38 +3073,61 @@ where } pub async fn get_mentioning_containers(&self, node_id: &str) -> Result> { - // 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, + #[serde(rename = "in")] + source: Thing, } let results: Vec = 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 = 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 = 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 = 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); } } @@ -3119,6 +3135,39 @@ where 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 { + // 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(¤t_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> { // Schema nodes use simple IDs (just the node type name, e.g., "date") // They're differentiated by node_type = "schema" diff --git a/packages/core/src/services/node_service.rs b/packages/core/src/services/node_service.rs index f24a52fe5..f3e87b8c6 100644 --- a/packages/core/src/services/node_service.rs +++ b/packages/core/src/services/node_service.rs @@ -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( @@ -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()))?; @@ -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(), }); @@ -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)) @@ -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(), });