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
125 changes: 82 additions & 43 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
136 changes: 27 additions & 109 deletions packages/core/src/operations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,51 +235,6 @@ where
// Private Helper Methods
// =========================================================================

/// Check if a node type is a container (date, topic, project)
///
/// Container types define their own scope and cannot have parent/container/sibling.
///
/// # Arguments
///
/// * `node_type` - The node type to check
///
/// # Returns
///
/// true if the node type is a container, false otherwise
///
/// # Examples
///
/// ```
/// # use nodespace_core::operations::NodeOperations;
/// # use nodespace_core::services::NodeService;
/// # use nodespace_core::db::SurrealStore;
/// # use std::path::PathBuf;
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// # let db = SurrealStore::new(PathBuf::from("./test.db")).await?;
/// # let node_service = NodeService::new(db)?;
/// # let operations = NodeOperations::new(node_service);
/// // These are internal examples for documentation
/// // assert!(operations.can_be_container_type("date"));
/// // assert!(operations.can_be_container_type("text"));
/// // assert!(!operations.can_be_container_type("task"));
/// # Ok(())
/// # }
/// ```
fn can_be_container_type(node_type: &str) -> bool {
// Check if a node type is ALLOWED to be a container.
// This does NOT mean the node IS a container - that depends on hierarchy fields
// (a node IS a container if parent_id=None AND container_node_id=None).
//
// Container-capable types:
// - date: Auto-created date containers (virtual)
// - text: Simple text containers (e.g., document titles)
// - header: Header containers (e.g., "# Project Name")
// - schema: Schema definitions (root nodes, infrastructure)
// Multi-line types (code-block, quote-block, ordered-list) cannot be containers
matches!(node_type, "date" | "text" | "header" | "schema")
}

/// Ensure date container exists, auto-creating it if necessary
///
/// Date nodes (YYYY-MM-DD format) are special containers that auto-exist.
Expand All @@ -298,10 +253,19 @@ where
return Ok(()); // Not a date, nothing to do
}

// Check if date container already exists
let exists = self.node_service.get_node(node_id).await?.is_some();
// Check if date container already exists IN THE DATABASE
// IMPORTANT: Call store.get_node() directly to bypass virtual date node logic
// in node_service.get_node(). The virtual date nodes are only for read operations,
// we need to check actual database state for auto-creation.
let exists = self
.node_service
.store
.get_node(node_id)
.await
.map_err(|e| NodeOperationError::DatabaseError(e.to_string()))?
.is_some();
if exists {
return Ok(()); // Already exists
return Ok(()); // Already exists in database
}

// Auto-create the date container
Expand Down Expand Up @@ -550,34 +514,12 @@ where
self.validate_parent_hierarchy(params.parent_id.as_deref())
.await?;

// Business Rule 1: Determine if this node IS a container (root) based on hierarchy
// A node is a container/root if it has NO parent
// (not just because its type CAN be a container)
let is_container_node = params.parent_id.is_none();

let (final_parent_id, _final_container_id, final_sibling_id) = if is_container_node {
// This node IS a container - validate that its type allows being a container
if !Self::can_be_container_type(&params.node_type) {
return Err(NodeOperationError::invalid_container_type(
params.content.clone(),
params.node_type.clone(),
));
}

// Container nodes MUST have before_sibling_id as None as well
if params.before_sibling_id.is_some() {
return Err(NodeOperationError::container_cannot_have_sibling(
params.content.clone(),
params.node_type.clone(),
));
}

(None, None, None)
} else {
// Non-container nodes: apply business rules

// Business Rule 2: Auto-derive container/root from parent chain
// The backend traverses parent edges to find the root node
// Business Rule 1: Auto-derive container/root from parent chain
// If node has a parent, traverse parent edges to find the root node
// If node has NO parent, it IS the root (container_id = None)
let (final_parent_id, _final_container_id, final_sibling_id) = if params.parent_id.is_some()
{
// Node has a parent - derive container from parent chain
let resolved_container = self
.resolve_container(
&params.content, // Passed for error context only (node ID not yet assigned)
Expand All @@ -586,7 +528,7 @@ where
)
.await?;

// Business Rule 3: Calculate sibling position
// Business Rule 2: Calculate sibling position
let calculated_sibling = self
.calculate_sibling_position(params.parent_id.as_deref(), params.before_sibling_id)
.await?;
Expand All @@ -596,6 +538,14 @@ where
Some(resolved_container),
calculated_sibling,
)
} else {
// Node has NO parent - it's a root-level node
// Root nodes can still have siblings (other root-level nodes)
let calculated_sibling = self
.calculate_sibling_position(None, params.before_sibling_id)
.await?;

(None, None, calculated_sibling)
};

// Create the node using NodeService
Expand Down Expand Up @@ -1490,38 +1440,6 @@ mod tests {
// No assertion needed - just verifying clone compiles and works
}

// =========================================================================
// Phase 2: Container Type Detection Tests
// =========================================================================

#[test]
fn test_can_be_container_type_date() {
assert!(NodeOperations::<surrealdb::engine::local::Db>::can_be_container_type("date"));
}

#[test]
fn test_can_be_container_type_text() {
assert!(NodeOperations::<surrealdb::engine::local::Db>::can_be_container_type("text"));
}

#[test]
fn test_can_be_container_type_header() {
assert!(NodeOperations::<surrealdb::engine::local::Db>::can_be_container_type("header"));
}

#[test]
fn test_can_be_container_type_task_is_not_container() {
assert!(!NodeOperations::<surrealdb::engine::local::Db>::can_be_container_type("task"));
}

#[test]
fn test_can_be_container_type_unknown_is_not_container() {
assert!(!NodeOperations::<surrealdb::engine::local::Db>::can_be_container_type("unknown"));
assert!(
!NodeOperations::<surrealdb::engine::local::Db>::can_be_container_type("custom-type")
);
}

// =========================================================================
// Critical Integration Tests for Business Rules
// =========================================================================
Expand Down
51 changes: 51 additions & 0 deletions packages/desktop-app/src-tauri/src/bin/dev-proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ async fn main() -> anyhow::Result<()> {
get(get_mentioning_containers),
)
// Schema endpoints
.route("/api/schemas", get(get_all_schemas))
.route("/api/schemas/:id", get(get_schema))
.route("/api/schemas/:id/fields", post(add_schema_field))
.route("/api/schemas/:id/fields/:name", delete(remove_schema_field))
Expand Down Expand Up @@ -724,6 +725,56 @@ async fn get_mentioning_containers(
Ok(Json(container_ids))
}

/// Get all schema definitions
///
/// Retrieves all schema definitions stored in the database. This endpoint
/// is used by the frontend SchemaPluginLoader to auto-register custom entity
/// plugins on application startup.
///
/// # HTTP Endpoint
/// ```text
/// GET /api/schemas
/// ```
///
/// # Response (200 OK)
/// ```json
/// [
/// {
/// "id": "task",
/// "content": "Task",
/// "version": 1,
/// "fields": [...]
/// },
/// {
/// "id": "date",
/// "content": "Date",
/// "version": 1,
/// "fields": [...]
/// }
/// ]
/// ```
///
/// # Errors
/// - `500 INTERNAL SERVER ERROR`: Database error
///
/// # Implementation Note
/// Returns schemas as SchemaDefinition objects (not tuples). The frontend
/// expects an array of schema objects with `id` and other properties.
async fn get_all_schemas(State(state): State<AppState>) -> ApiResult<Vec<SchemaDefinition>> {
let schemas = state
.schema_service
.get_all_schemas()
.await
.map_err(map_schema_error)?;

// Convert Vec<(String, SchemaDefinition)> to Vec<SchemaDefinition>
// The tuple format is internal to SchemaService; the HTTP API returns objects
let schema_list: Vec<SchemaDefinition> =
schemas.into_iter().map(|(_id, schema)| schema).collect();

Ok(Json(schema_list))
}

/// Get schema definition by schema ID
///
/// Retrieves the complete schema definition including all fields,
Expand Down
Loading