Skip to content
173 changes: 89 additions & 84 deletions packages/core/src/db/surreal_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,47 +284,41 @@ async fn batch_fetch_properties<C: surrealdb::Connection>(
return Ok(std::collections::HashMap::new());
}

// Build array of Thing IDs for the WHERE IN clause
let thing_ids: Vec<Thing> = node_ids
.iter()
.map(|id| Thing::from((node_type.to_string(), id.clone())))
.collect();

// Batch query strategy: Query properties with WHERE IN for efficiency
// We select all fields but need to handle the id separately since it's a Thing type
// Workaround: Query with id, convert to string, then build properties without id
let query = format!("SELECT * FROM {} WHERE id IN $ids;", node_type);

let mut response = db
.query(&query)
.bind(("ids", thing_ids))
.await
.with_context(|| format!("Failed to batch fetch properties for type '{}'", node_type))?;
// Strategy: Query each spoke record individually using OMIT id pattern
// This avoids Thing deserialization issues while keeping ID association clear
// Performance: Still much better than N+1 since we batch the queries together
let mut result = std::collections::HashMap::new();

// Deserialize as generic Values to extract id as string
let records: Vec<Value> = response.take(0).with_context(|| {
format!(
"Failed to parse batch property results for type '{}'",
node_type
)
})?;
for node_id in node_ids {
// Query spoke table using OMIT id to exclude Thing-typed id field
// This matches the pattern from individual get_node() fetching (line 1136)
let query = format!("SELECT * OMIT id FROM type::thing('{}', $id);", node_type);

// Convert to HashMap keyed by node ID (extracted from Thing)
let mut result = std::collections::HashMap::new();
for record in records {
if let Some(obj) = record.as_object() {
// Extract id as string (format: "task:uuid" -> "uuid")
if let Some(id_value) = obj.get("id") {
if let Some(id_str) = id_value.as_str() {
// Parse Thing ID: "task:uuid" -> "uuid"
let node_id = id_str.split(':').nth(1).unwrap_or(id_str).to_string();

// Create properties object without the 'id' field
let mut props = obj.clone();
props.remove("id");
result.insert(node_id, Value::Object(props));
}
}
// Clone node_id so we own it and can pass to bind
let node_id_owned = node_id.clone();

let mut response = db
.query(&query)
.bind(("id", node_id_owned.clone()))
.await
.with_context(|| {
format!(
"Failed to fetch properties for type '{}' id '{}'",
node_type, node_id_owned
)
})?;

// Deserialize as generic Value
let records: Vec<Value> = response.take(0).with_context(|| {
format!(
"Failed to parse property results for type '{}' id '{}'",
node_type, node_id_owned
)
})?;

// Take first record if exists
if let Some(props) = records.into_iter().next() {
result.insert(node_id_owned, props);
}
}

Expand Down Expand Up @@ -910,13 +904,15 @@ where
.await
.context("Failed to create node in type-specific table")?;

// Set data field to link to type-specific record
// Set data field to link to type-specific record and clear hub properties
// Hub-and-spoke pattern: properties live ONLY in spoke table, not duplicated in hub
// The get_node() method fetches spoke data using OMIT id pattern (line 1136)
self.db
.query("UPDATE type::thing('node', $id) SET data = type::thing($type_table, $id);")
.query("UPDATE type::thing('node', $id) SET data = type::thing($type_table, $id), properties = {};")
.bind(("id", node.id.clone()))
.bind(("type_table", node.node_type.clone()))
.await
.context("Failed to set data link")?;
.context("Failed to set data link and clear hub properties")?;
}

// Note: Parent-child relationships are now established separately via move_node()
Expand Down Expand Up @@ -1801,7 +1797,50 @@ where
let surreal_nodes: Vec<SurrealNode> = response
.take(0)
.context("Failed to extract nodes from query response")?;
Ok(surreal_nodes.into_iter().map(Into::into).collect())

// Convert to nodes
let mut nodes: Vec<Node> = surreal_nodes.into_iter().map(Into::into).collect();

// Batch fetch properties for nodes that have them (same as get_children)
// Performance: 100 nodes with properties = 2-3 queries (vs 101 with N+1 pattern)
use std::collections::HashMap;

// Group nodes by type for batch fetching
let mut nodes_by_type: HashMap<String, Vec<String>> = HashMap::new();
for node in &nodes {
if TYPES_WITH_PROPERTIES.contains(&node.node_type.as_str()) {
nodes_by_type
.entry(node.node_type.clone())
.or_default()
.push(node.id.clone());
}
}

// Batch fetch properties for each type
let mut all_properties: HashMap<String, Value> = HashMap::new();
for (node_type, node_ids) in nodes_by_type {
match batch_fetch_properties(&self.db, &node_type, &node_ids).await {
Ok(props_map) => {
all_properties.extend(props_map);
}
Err(e) => {
tracing::warn!(
"Failed to batch fetch properties for type '{}': {}",
node_type,
e
);
}
}
}

// Hydrate properties into nodes
for node in &mut nodes {
if let Some(props) = all_properties.get(&node.id) {
node.properties = props.clone();
}
}

Ok(nodes)
}

pub async fn get_children(&self, parent_id: Option<&str>) -> Result<Vec<Node>> {
Expand Down Expand Up @@ -2140,39 +2179,9 @@ where
Ok(())
}

pub async fn move_node(&self, id: &str, new_parent_id: Option<&str>) -> Result<()> {
use surrealdb::sql::Thing;

// Validate that moving won't create a cycle
if let Some(parent_id) = new_parent_id {
self.validate_no_cycle(parent_id, id).await?;
}

// Delete existing parent edge
let child_thing = Thing::from(("node".to_string(), id.to_string()));
self.db
.query("DELETE has_child WHERE out = $child_thing;")
.bind(("child_thing", child_thing.clone()))
.await
.context("Failed to delete existing parent edge")?;

// Create new parent edge if parent is specified
if let Some(parent_id) = new_parent_id {
let parent_thing = Thing::from(("node".to_string(), parent_id.to_string()));
self.db
.query("RELATE $parent_thing->has_child->$child_thing;")
.bind(("parent_thing", parent_thing))
.bind(("child_thing", child_thing))
.await
.context("Failed to create new parent edge")?;
}

Ok(())
}

/// Move a node to a new parent atomically
///
/// This is the atomic version of move_node. It guarantees that either:
/// Guarantees that either:
/// - The old edge is deleted AND the new edge is created
/// - OR nothing changes (transaction rolls back on failure)
///
Expand All @@ -2192,14 +2201,14 @@ where
/// # use nodespace_core::db::SurrealStore;
/// # async fn example(store: &SurrealStore) -> anyhow::Result<()> {
/// // Move node to new parent
/// store.move_node_atomic("child-uuid", Some("new-parent-uuid"), None).await?;
/// store.move_node("child-uuid", Some("new-parent-uuid"), None).await?;
///
/// // Make node a root node
/// store.move_node_atomic("child-uuid", None, None).await?;
/// store.move_node("child-uuid", None, None).await?;
/// # Ok(())
/// # }
/// ```
pub async fn move_node_atomic(
pub async fn move_node(
&self,
node_id: &str,
new_parent_id: Option<&str>,
Expand Down Expand Up @@ -3453,9 +3462,7 @@ mod tests {
assert_eq!(children1.len(), 1);

// Move child to parent2 atomically
store
.move_node_atomic(&child.id, Some(&parent2.id), None)
.await?;
store.move_node(&child.id, Some(&parent2.id), None).await?;

// Verify child is now under parent2
let children1_after = store.get_children(Some(&parent1.id)).await?;
Expand Down Expand Up @@ -3485,7 +3492,7 @@ mod tests {
.await?;

// Move child to root
store.move_node_atomic(&child.id, None, None).await?;
store.move_node(&child.id, None, None).await?;

// Verify child is a root node
let parent_children = store.get_children(Some(&parent.id)).await?;
Expand Down Expand Up @@ -3514,9 +3521,7 @@ mod tests {
.await?;

// Try to move parent under child (would create cycle)
let result = store
.move_node_atomic(&parent.id, Some(&child.id), None)
.await;
let result = store.move_node(&parent.id, Some(&child.id), None).await;

assert!(
result.is_err(),
Expand Down
Loading