diff --git a/packages/core/src/behaviors/mod.rs b/packages/core/src/behaviors/mod.rs index b90bffdbb..2eb9d1da0 100644 --- a/packages/core/src/behaviors/mod.rs +++ b/packages/core/src/behaviors/mod.rs @@ -748,6 +748,56 @@ impl NodeBehavior for DateNodeBehavior { } } +/// Schema node behavior +/// +/// Schema nodes store entity type definitions using the Pure JSON schema-as-node pattern. +/// By convention, schema nodes have `id = type_name` and `node_type = "schema"`. +/// +/// Schema nodes are validated minimally - they just need non-empty content and valid +/// properties JSON. The SchemaService handles detailed schema validation. +pub struct SchemaNodeBehavior; + +impl NodeBehavior for SchemaNodeBehavior { + fn type_name(&self) -> &'static str { + "schema" + } + + fn validate(&self, node: &Node) -> Result<(), NodeValidationError> { + // Basic validation - non-empty content + if is_empty_or_whitespace(&node.content) { + return Err(NodeValidationError::MissingField( + "Schema nodes must have content (schema name)".to_string(), + )); + } + + // Properties should be valid JSON object (detailed validation is in SchemaService) + if !node.properties.is_object() { + return Err(NodeValidationError::InvalidProperties( + "Schema properties must be a JSON object".to_string(), + )); + } + + Ok(()) + } + + fn can_have_children(&self) -> bool { + false // Schemas are metadata nodes, not containers + } + + fn supports_markdown(&self) -> bool { + false // Schemas are structured data + } + + fn default_metadata(&self) -> serde_json::Value { + serde_json::json!({ + "is_core": false, + "version": 1, + "description": "", + "fields": [] + }) + } +} + /// Registry for managing node behaviors /// /// The registry provides thread-safe storage and retrieval of node behaviors. @@ -826,6 +876,7 @@ impl NodeBehaviorRegistry { registry.register(Arc::new(QuoteBlockNodeBehavior)); registry.register(Arc::new(OrderedListNodeBehavior)); registry.register(Arc::new(DateNodeBehavior)); + registry.register(Arc::new(SchemaNodeBehavior)); registry } diff --git a/packages/core/src/mcp/handlers/mod.rs b/packages/core/src/mcp/handlers/mod.rs index 81bc16a54..0ba33f8f2 100644 --- a/packages/core/src/mcp/handlers/mod.rs +++ b/packages/core/src/mcp/handlers/mod.rs @@ -5,5 +5,6 @@ pub mod initialize; pub mod markdown; pub mod nodes; +pub mod schema; pub mod search; pub mod tools; diff --git a/packages/core/src/mcp/handlers/nodes.rs b/packages/core/src/mcp/handlers/nodes.rs index 0f223a9ed..a1edc1fd4 100644 --- a/packages/core/src/mcp/handlers/nodes.rs +++ b/packages/core/src/mcp/handlers/nodes.rs @@ -4,8 +4,10 @@ //! Pure business logic - no Tauri dependencies. use crate::mcp::types::MCPError; +use crate::models::schema::ProtectionLevel; use crate::models::{NodeFilter, OrderBy}; use crate::operations::{CreateNodeParams, NodeOperationError, NodeOperations}; +use crate::services::SchemaService; use chrono::NaiveDate; use serde::Deserialize; use serde_json::{json, Value}; @@ -253,11 +255,65 @@ pub async fn handle_get_node( /// Handle update_node MCP request pub async fn handle_update_node( operations: &Arc, + schema_service: &Arc, params: Value, ) -> Result { let params: UpdateNodeParams = serde_json::from_value(params) .map_err(|e| MCPError::invalid_params(format!("Invalid parameters: {}", e)))?; + // Validate schema changes if properties are being updated + if let Some(ref new_properties) = params.properties { + // Check if this node's type has a schema + if let Ok(Some(existing_node)) = operations.get_node(¶ms.node_id).await { + if let Ok(schema) = schema_service.get_schema(&existing_node.node_type).await { + // Get old properties for comparison (with proper ownership) + let empty_map = serde_json::Map::new(); + let old_properties = existing_node.properties.as_object().unwrap_or(&empty_map); + let new_props_obj = new_properties.as_object().ok_or_else(|| { + MCPError::invalid_params("properties must be an object".to_string()) + })?; + + // Check for core field deletions + for field in &schema.fields { + if field.protection == ProtectionLevel::Core { + let old_has_field = old_properties.contains_key(&field.name); + let new_has_field = new_props_obj.contains_key(&field.name); + + if old_has_field && !new_has_field { + return Err(MCPError::invalid_params(format!( + "Cannot delete core field '{}' from schema '{}'", + field.name, existing_node.node_type + ))); + } + } + } + + // Check for core field type changes (detect by checking if new value type differs from schema type) + // This is a basic check - full type validation would require more complex logic + for field in &schema.fields { + if field.protection == ProtectionLevel::Core { + if let Some(new_value) = new_props_obj.get(&field.name) { + // Basic type validation: ensure enum fields only have allowed values + if field.field_type == "enum" { + if let Some(value_str) = new_value.as_str() { + let allowed_values = + schema.get_enum_values(&field.name).unwrap_or_default(); + if !allowed_values.contains(&value_str.to_string()) { + // Check if it's trying to set a value that's not in core or user values + return Err(MCPError::invalid_params(format!( + "Invalid enum value '{}' for field '{}'. Allowed values: {:?}", + value_str, field.name, allowed_values + ))); + } + } + } + } + } + } + } + } + } + // Update node via NodeOperations (enforces Rule 5: content updates only, no hierarchy changes) // // MCP update_node intentionally restricts certain fields for data integrity: @@ -905,10 +961,11 @@ pub struct BatchUpdateFailure { /// { "id": "task-2", "properties": { "priority": "high" } } /// ] /// }); -/// let result = handle_update_nodes_batch(&operations, params).await?; +/// let result = handle_update_nodes_batch(&operations, &schema_service, params).await?; /// ``` pub async fn handle_update_nodes_batch( operations: &Arc, + schema_service: &Arc, params: Value, ) -> Result { // Parse parameters @@ -964,6 +1021,59 @@ pub async fn handle_update_nodes_batch( } }; + // Validate schema changes if properties are being updated + if let Some(ref new_properties) = update.properties { + if let Ok(Some(existing_node)) = operations.get_node(&update.id).await { + if let Ok(schema) = schema_service.get_schema(&existing_node.node_type).await { + let empty_map = serde_json::Map::new(); + let old_properties = existing_node.properties.as_object().unwrap_or(&empty_map); + + if let Some(new_props_obj) = new_properties.as_object() { + // Check for core field deletions + for field in &schema.fields { + if field.protection == ProtectionLevel::Core { + let old_has_field = old_properties.contains_key(&field.name); + let new_has_field = new_props_obj.contains_key(&field.name); + + if old_has_field && !new_has_field { + failed.push(BatchUpdateFailure { + id: update.id.clone(), + error: format!( + "Cannot delete core field '{}' from schema '{}'", + field.name, existing_node.node_type + ), + }); + continue; + } + } + + // Validate enum values + if field.protection == ProtectionLevel::Core + && field.field_type == "enum" + { + if let Some(new_value) = new_props_obj.get(&field.name) { + if let Some(value_str) = new_value.as_str() { + let allowed_values = + schema.get_enum_values(&field.name).unwrap_or_default(); + if !allowed_values.contains(&value_str.to_string()) { + failed.push(BatchUpdateFailure { + id: update.id.clone(), + error: format!( + "Invalid enum value '{}' for field '{}'. Allowed values: {:?}", + value_str, field.name, allowed_values + ), + }); + continue; + } + } + } + } + } + } + } + } + } + // Apply update via NodeOperations (enforces all business rules) match operations .update_node( diff --git a/packages/core/src/mcp/handlers/nodes_test.rs b/packages/core/src/mcp/handlers/nodes_test.rs index fe705c58f..cbe090181 100644 --- a/packages/core/src/mcp/handlers/nodes_test.rs +++ b/packages/core/src/mcp/handlers/nodes_test.rs @@ -139,25 +139,28 @@ mod occ_tests { use crate::mcp::handlers::nodes::{handle_delete_node, handle_update_node}; use crate::mcp::types::{INVALID_PARAMS, VERSION_CONFLICT}; use crate::operations::{CreateNodeParams, NodeOperations}; + use crate::services::SchemaService; use crate::{DatabaseService, NodeService}; use serde_json::json; use std::sync::Arc; use tempfile::TempDir; async fn setup_test_operations( - ) -> Result<(Arc, TempDir), Box> { + ) -> Result<(Arc, Arc, TempDir), Box> + { let temp_dir = TempDir::new()?; let db_path = temp_dir.path().join("test.db"); let db = DatabaseService::new(db_path).await?; - let node_service = NodeService::new(db)?; - let operations = Arc::new(NodeOperations::new(Arc::new(node_service))); - Ok((operations, temp_dir)) + let node_service = Arc::new(NodeService::new(db)?); + let operations = Arc::new(NodeOperations::new(node_service.clone())); + let schema_service = Arc::new(SchemaService::new(node_service)); + Ok((operations, schema_service, temp_dir)) } /// Verifies nodes are created with version 1 #[tokio::test] async fn test_node_created_with_version_1() { - let (operations, _temp) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp) = setup_test_operations().await.unwrap(); let node_id = operations .create_node(CreateNodeParams { @@ -179,7 +182,7 @@ mod occ_tests { /// Verifies version increments on successful update #[tokio::test] async fn test_version_increments_on_update() { - let (operations, _temp) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp) = setup_test_operations().await.unwrap(); let node_id = operations .create_node(CreateNodeParams { @@ -201,7 +204,9 @@ mod occ_tests { "content": "Updated once" }); - let result = handle_update_node(&operations, params).await.unwrap(); + let result = handle_update_node(&operations, &_schema_service, params) + .await + .unwrap(); assert_eq!(result["version"], 2); // Second update: version 2 → 3 @@ -211,14 +216,16 @@ mod occ_tests { "content": "Updated twice" }); - let result2 = handle_update_node(&operations, params2).await.unwrap(); + let result2 = handle_update_node(&operations, &_schema_service, params2) + .await + .unwrap(); assert_eq!(result2["version"], 3); } /// Verifies concurrent update detection via version conflict #[tokio::test] async fn test_concurrent_update_version_conflict() { - let (operations, _temp) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp) = setup_test_operations().await.unwrap(); // Create node (version=1) let node_id = operations @@ -240,7 +247,9 @@ mod occ_tests { "version": 1, "content": "Client 1 update" }); - handle_update_node(&operations, params1).await.unwrap(); + handle_update_node(&operations, &_schema_service, params1) + .await + .unwrap(); // Client 2 tries to update with stale version (still thinks version=1) let params2 = json!({ @@ -249,7 +258,7 @@ mod occ_tests { "content": "Client 2 conflicting update" }); - let result = handle_update_node(&operations, params2).await; + let result = handle_update_node(&operations, &_schema_service, params2).await; // Should fail with VersionConflict error assert!(result.is_err()); @@ -261,7 +270,7 @@ mod occ_tests { /// Verifies conflict error includes current node state #[tokio::test] async fn test_version_conflict_includes_current_node() { - let (operations, _temp) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp) = setup_test_operations().await.unwrap(); let node_id = operations .create_node(CreateNodeParams { @@ -282,7 +291,9 @@ mod occ_tests { "version": 1, "content": "First update" }); - handle_update_node(&operations, params1).await.unwrap(); + handle_update_node(&operations, &_schema_service, params1) + .await + .unwrap(); // Try to update with stale version let params2 = json!({ @@ -291,7 +302,7 @@ mod occ_tests { "content": "Conflicting update" }); - let result = handle_update_node(&operations, params2).await; + let result = handle_update_node(&operations, &_schema_service, params2).await; assert!(result.is_err()); let error = result.unwrap_err(); @@ -310,7 +321,7 @@ mod occ_tests { /// Verifies delete operation checks version #[tokio::test] async fn test_delete_with_version_check() { - let (operations, _temp) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp) = setup_test_operations().await.unwrap(); let node_id = operations .create_node(CreateNodeParams { @@ -331,7 +342,7 @@ mod occ_tests { "version": 1, "content": "Modified" }); - handle_update_node(&operations, update_params) + handle_update_node(&operations, &_schema_service, update_params) .await .unwrap(); @@ -366,7 +377,7 @@ mod occ_tests { /// Verifies rapid sequential updates maintain version integrity #[tokio::test] async fn test_rapid_sequential_updates() { - let (operations, _temp) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp) = setup_test_operations().await.unwrap(); let node_id = operations .create_node(CreateNodeParams { @@ -390,7 +401,9 @@ mod occ_tests { "content": format!("Update {}", i + 1) }); - let result = handle_update_node(&operations, params).await.unwrap(); + let result = handle_update_node(&operations, &_schema_service, params) + .await + .unwrap(); current_version = result["version"].as_i64().unwrap(); assert_eq!(current_version, (i + 2) as i64); } @@ -406,7 +419,7 @@ mod occ_tests { /// Verifies property-only updates increment version #[tokio::test] async fn test_property_update_increments_version() { - let (operations, _temp) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp) = setup_test_operations().await.unwrap(); let node_id = operations .create_node(CreateNodeParams { @@ -428,7 +441,9 @@ mod occ_tests { "properties": {"status": "published", "priority": "high"} }); - let result = handle_update_node(&operations, params).await.unwrap(); + let result = handle_update_node(&operations, &_schema_service, params) + .await + .unwrap(); assert_eq!(result["version"], 2); let updated = operations.get_node(&node_id).await.unwrap().unwrap(); @@ -440,7 +455,7 @@ mod occ_tests { /// Verifies update FAILS when version parameter is missing (prevents TOCTOU race conditions) #[tokio::test] async fn test_update_without_version_parameter() { - let (operations, _temp) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp) = setup_test_operations().await.unwrap(); let node_id = operations .create_node(CreateNodeParams { @@ -461,7 +476,7 @@ mod occ_tests { "content": "Updated without version" }); - let result = handle_update_node(&operations, params).await; + let result = handle_update_node(&operations, &_schema_service, params).await; // Should fail with invalid_params error assert!(result.is_err()); @@ -488,24 +503,27 @@ mod integration_tests { handle_update_nodes_batch, }; use crate::operations::{CreateNodeParams, NodeOperations}; + use crate::services::SchemaService; use crate::{DatabaseService, NodeService}; use serde_json::json; use std::sync::Arc; use tempfile::TempDir; async fn setup_test_operations( - ) -> Result<(Arc, TempDir), Box> { + ) -> Result<(Arc, Arc, TempDir), Box> + { let temp_dir = TempDir::new()?; let db_path = temp_dir.path().join("test.db"); let db = DatabaseService::new(db_path).await?; - let node_service = NodeService::new(db)?; - let operations = Arc::new(NodeOperations::new(Arc::new(node_service))); - Ok((operations, temp_dir)) + let node_service = Arc::new(NodeService::new(db)?); + let operations = Arc::new(NodeOperations::new(node_service.clone())); + let schema_service = Arc::new(SchemaService::new(node_service)); + Ok((operations, schema_service, temp_dir)) } #[tokio::test] async fn test_insert_child_at_index_with_date_auto_creation() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp_dir) = setup_test_operations().await.unwrap(); // Insert child with date parent (should auto-create date node) let params = json!({ @@ -540,7 +558,7 @@ mod integration_tests { #[tokio::test] async fn test_insert_child_at_index_with_invalid_date_format() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp_dir) = setup_test_operations().await.unwrap(); // Try to insert with invalid date format (should fail) let params = json!({ @@ -561,7 +579,7 @@ mod integration_tests { #[tokio::test] async fn test_insert_child_at_index_with_non_date_invalid_parent() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp_dir) = setup_test_operations().await.unwrap(); // Try to insert with non-existent non-date parent let params = json!({ @@ -582,7 +600,7 @@ mod integration_tests { #[tokio::test] async fn test_move_child_to_index_beyond_sibling_count() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp_dir) = setup_test_operations().await.unwrap(); // Create date container let date = operations @@ -678,7 +696,7 @@ mod integration_tests { #[tokio::test] async fn test_get_node_tree_with_max_depth_1() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp_dir) = setup_test_operations().await.unwrap(); // Create date container let date = operations @@ -767,7 +785,7 @@ mod integration_tests { #[tokio::test] async fn test_get_child_at_index_out_of_bounds() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp_dir) = setup_test_operations().await.unwrap(); // Create date container let date = operations @@ -829,7 +847,7 @@ mod integration_tests { #[tokio::test] async fn test_get_children_ordered_with_multiple_insertions() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp_dir) = setup_test_operations().await.unwrap(); // Create date container let date = operations @@ -912,7 +930,7 @@ mod integration_tests { #[tokio::test] async fn test_get_node_tree_max_depth_validation() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp_dir) = setup_test_operations().await.unwrap(); // Create a simple node let node = operations @@ -968,7 +986,7 @@ mod integration_tests { /// Verifies successful batch retrieval of multiple nodes #[tokio::test] async fn test_get_nodes_batch_success() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp_dir) = setup_test_operations().await.unwrap(); // Create test nodes let node1 = operations @@ -1027,7 +1045,7 @@ mod integration_tests { /// Verifies get_nodes_batch returns partial results when some nodes don't exist #[tokio::test] async fn test_get_nodes_batch_with_not_found() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp_dir) = setup_test_operations().await.unwrap(); let node1 = operations .create_node(CreateNodeParams { @@ -1060,7 +1078,7 @@ mod integration_tests { /// Verifies validation rejects empty node_ids array #[tokio::test] async fn test_get_nodes_batch_empty_input() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp_dir) = setup_test_operations().await.unwrap(); let params = json!({ "node_ids": [] @@ -1076,7 +1094,7 @@ mod integration_tests { /// Verifies batch size limit enforcement (max 100 nodes) #[tokio::test] async fn test_get_nodes_batch_exceeds_limit() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp_dir) = setup_test_operations().await.unwrap(); // Create array with 101 IDs (exceeds limit of 100) let node_ids: Vec = (0..101).map(|i| format!("node-{}", i)).collect(); @@ -1095,7 +1113,7 @@ mod integration_tests { /// Verifies successful batch update of multiple nodes #[tokio::test] async fn test_update_nodes_batch_success() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, schema_service, _temp_dir) = setup_test_operations().await.unwrap(); // Create a container first let container = operations @@ -1145,7 +1163,7 @@ mod integration_tests { ] }); - let result = handle_update_nodes_batch(&operations, params) + let result = handle_update_nodes_batch(&operations, &schema_service, params) .await .unwrap(); @@ -1163,7 +1181,7 @@ mod integration_tests { /// Verifies partial success handling with detailed failure reporting #[tokio::test] async fn test_update_nodes_batch_partial_failure() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, schema_service, _temp_dir) = setup_test_operations().await.unwrap(); let node1 = operations .create_node(CreateNodeParams { @@ -1186,7 +1204,7 @@ mod integration_tests { ] }); - let result = handle_update_nodes_batch(&operations, params) + let result = handle_update_nodes_batch(&operations, &schema_service, params) .await .unwrap(); @@ -1204,13 +1222,13 @@ mod integration_tests { /// Verifies validation rejects empty updates array #[tokio::test] async fn test_update_nodes_batch_empty_input() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, schema_service, _temp_dir) = setup_test_operations().await.unwrap(); let params = json!({ "updates": [] }); - let result = handle_update_nodes_batch(&operations, params).await; + let result = handle_update_nodes_batch(&operations, &schema_service, params).await; assert!(result.is_err()); let error = result.unwrap_err(); @@ -1220,7 +1238,7 @@ mod integration_tests { /// Verifies batch size limit enforcement (max 100 updates) #[tokio::test] async fn test_update_nodes_batch_exceeds_limit() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, schema_service, _temp_dir) = setup_test_operations().await.unwrap(); // Create array with 101 updates (exceeds limit of 100) let updates: Vec = (0..101) @@ -1236,7 +1254,7 @@ mod integration_tests { "updates": updates }); - let result = handle_update_nodes_batch(&operations, params).await; + let result = handle_update_nodes_batch(&operations, &schema_service, params).await; assert!(result.is_err()); let error = result.unwrap_err(); @@ -1246,7 +1264,7 @@ mod integration_tests { /// Verifies property-only updates without content changes #[tokio::test] async fn test_update_nodes_batch_with_properties() { - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, schema_service, _temp_dir) = setup_test_operations().await.unwrap(); let node = operations .create_node(CreateNodeParams { @@ -1268,7 +1286,7 @@ mod integration_tests { ] }); - let result = handle_update_nodes_batch(&operations, params) + let result = handle_update_nodes_batch(&operations, &schema_service, params) .await .unwrap(); @@ -1294,7 +1312,7 @@ mod integration_tests { async fn benchmark_get_nodes_batch() { use std::time::Instant; - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp_dir) = setup_test_operations().await.unwrap(); // Create 50 test nodes let mut node_ids = Vec::new(); @@ -1358,7 +1376,7 @@ mod integration_tests { async fn benchmark_update_nodes_batch() { use std::time::Instant; - let (operations, _temp_dir) = setup_test_operations().await.unwrap(); + let (operations, schema_service, _temp_dir) = setup_test_operations().await.unwrap(); // Create a container let container = operations @@ -1439,7 +1457,7 @@ mod integration_tests { let params = json!({ "updates": updates }); - let _result = handle_update_nodes_batch(&operations, params) + let _result = handle_update_nodes_batch(&operations, &schema_service, params) .await .unwrap(); let duration_batch = start_batch.elapsed(); @@ -1467,7 +1485,7 @@ mod integration_tests { /// This test validates the performance acceptance criterion #[tokio::test] async fn test_occ_performance_overhead() { - let (operations, _temp) = setup_test_operations().await.unwrap(); + let (operations, _schema_service, _temp) = setup_test_operations().await.unwrap(); // Create a test node let node_id = operations diff --git a/packages/core/src/mcp/handlers/schema.rs b/packages/core/src/mcp/handlers/schema.rs new file mode 100644 index 000000000..397b29571 --- /dev/null +++ b/packages/core/src/mcp/handlers/schema.rs @@ -0,0 +1,572 @@ +//! MCP Schema Management Handlers +//! +//! Provides specialized MCP tools for schema operations with better UX +//! than using generic update_node. Enforces protection levels and provides +//! clear error messages. + +use crate::mcp::types::MCPError; +use crate::models::schema::ProtectionLevel; +use crate::services::SchemaService; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::sync::Arc; + +/// Parameters for add_schema_field MCP method +#[derive(Debug, Deserialize)] +pub struct AddSchemaFieldParams { + /// Schema ID to modify (matches node_type) + pub schema_id: String, + /// Field name + pub field_name: String, + /// Field type (e.g., "string", "number", "boolean", "enum", "array") + pub field_type: String, + /// Whether this field should be indexed + #[serde(default)] + pub indexed: bool, + /// Whether this field is required + #[serde(default)] + pub required: Option, + /// Default value for the field + #[serde(default)] + pub default: Option, + /// Field description + #[serde(default)] + pub description: Option, + /// For array fields, the type of items in the array + #[serde(default)] + pub item_type: Option, + /// For enum fields, the allowed values (added to user_values) + #[serde(default)] + pub enum_values: Option>, + /// For enum fields, whether users can extend with more values + #[serde(default)] + pub extensible: Option, +} + +/// Parameters for remove_schema_field MCP method +#[derive(Debug, Deserialize)] +pub struct RemoveSchemaFieldParams { + /// Schema ID to modify + pub schema_id: String, + /// Field name to remove + pub field_name: String, +} + +/// Parameters for extend_schema_enum MCP method +#[derive(Debug, Deserialize)] +pub struct ExtendSchemaEnumParams { + /// Schema ID to modify + pub schema_id: String, + /// Enum field name + pub field_name: String, + /// New value to add to user_values + pub value: String, +} + +/// Parameters for remove_schema_enum_value MCP method +#[derive(Debug, Deserialize)] +pub struct RemoveSchemaEnumValueParams { + /// Schema ID to modify + pub schema_id: String, + /// Enum field name + pub field_name: String, + /// Value to remove from user_values + pub value: String, +} + +/// Parameters for get_schema_definition MCP method +#[derive(Debug, Deserialize)] +pub struct GetSchemaDefinitionParams { + /// Schema ID to retrieve + pub schema_id: String, +} + +/// Add a new field to a schema +/// +/// # MCP Tool Description +/// Add a new user-protected field to an existing schema. Core and system fields +/// cannot be added through MCP (only user fields allowed). The schema version +/// will be incremented automatically. +/// +/// # Parameters +/// - `schema_id`: ID of the schema to modify (matches node_type) +/// - `field_name`: Name of the new field +/// - `field_type`: Type of the field (string, number, boolean, enum, array) +/// - `indexed`: Whether to index this field for search (default: false) +/// - `required`: Whether this field is required (optional) +/// - `default`: Default value for the field (optional) +/// - `description`: Field description (optional) +/// - `item_type`: For array fields, the type of items (optional) +/// - `enum_values`: For enum fields, the allowed values (optional, added to user_values) +/// - `extensible`: For enum fields, whether users can add more values (optional) +/// +/// # Returns +/// - `schema_id`: ID of the modified schema +/// - `new_version`: New schema version after adding the field +/// - `success`: true +/// +/// # Errors +/// - `VALIDATION_ERROR`: If trying to add a non-user field or field already exists +/// - `NODE_NOT_FOUND`: If schema doesn't exist +pub async fn handle_add_schema_field( + schema_service: &Arc, + params: Value, +) -> Result { + let params: AddSchemaFieldParams = serde_json::from_value(params) + .map_err(|e| MCPError::invalid_params(format!("Invalid parameters: {}", e)))?; + + // Build field definition + let field = crate::models::schema::SchemaField { + name: params.field_name.clone(), + field_type: params.field_type, + protection: ProtectionLevel::User, // Always user-protected when added via MCP + core_values: None, + user_values: params.enum_values, + indexed: params.indexed, + required: params.required, + extensible: params.extensible, + default: params.default, + description: params.description, + item_type: params.item_type, + }; + + // Add field via SchemaService + schema_service + .add_field(¶ms.schema_id, field) + .await + .map_err(|e| MCPError::validation_error(e.to_string()))?; + + // Get updated schema to return new version + let schema = schema_service + .get_schema(¶ms.schema_id) + .await + .map_err(|e| MCPError::internal_error(format!("Failed to retrieve schema: {}", e)))?; + + Ok(json!({ + "schema_id": params.schema_id, + "new_version": schema.version, + "success": true + })) +} + +/// Remove a field from a schema +/// +/// # MCP Tool Description +/// Remove a user-protected field from an existing schema. Core and system fields +/// cannot be removed (protection enforcement). The schema version will be +/// incremented automatically. +/// +/// # Parameters +/// - `schema_id`: ID of the schema to modify +/// - `field_name`: Name of the field to remove +/// +/// # Returns +/// - `schema_id`: ID of the modified schema +/// - `new_version`: New schema version after removing the field +/// - `success`: true +/// +/// # Errors +/// - `VALIDATION_ERROR`: If trying to remove a core/system field or field doesn't exist +/// - `NODE_NOT_FOUND`: If schema doesn't exist +pub async fn handle_remove_schema_field( + schema_service: &Arc, + params: Value, +) -> Result { + let params: RemoveSchemaFieldParams = serde_json::from_value(params) + .map_err(|e| MCPError::invalid_params(format!("Invalid parameters: {}", e)))?; + + // Remove field via SchemaService + schema_service + .remove_field(¶ms.schema_id, ¶ms.field_name) + .await + .map_err(|e| MCPError::validation_error(e.to_string()))?; + + // Get updated schema to return new version + let schema = schema_service + .get_schema(¶ms.schema_id) + .await + .map_err(|e| MCPError::internal_error(format!("Failed to retrieve schema: {}", e)))?; + + Ok(json!({ + "schema_id": params.schema_id, + "new_version": schema.version, + "success": true + })) +} + +/// Extend an enum field with a new value +/// +/// # MCP Tool Description +/// Add a new value to an enum field's user_values list. The enum field must be +/// marked as extensible. Core enum values cannot be modified. The schema version +/// will be incremented automatically. +/// +/// # Parameters +/// - `schema_id`: ID of the schema to modify +/// - `field_name`: Name of the enum field +/// - `value`: New value to add to user_values +/// +/// # Returns +/// - `schema_id`: ID of the modified schema +/// - `new_version`: New schema version after adding the value +/// - `success`: true +/// +/// # Errors +/// - `VALIDATION_ERROR`: If field is not enum, not extensible, or value already exists +/// - `NODE_NOT_FOUND`: If schema or field doesn't exist +pub async fn handle_extend_schema_enum( + schema_service: &Arc, + params: Value, +) -> Result { + let params: ExtendSchemaEnumParams = serde_json::from_value(params) + .map_err(|e| MCPError::invalid_params(format!("Invalid parameters: {}", e)))?; + + // Extend enum via SchemaService + schema_service + .extend_enum_field(¶ms.schema_id, ¶ms.field_name, params.value) + .await + .map_err(|e| MCPError::validation_error(e.to_string()))?; + + // Get updated schema to return new version + let schema = schema_service + .get_schema(¶ms.schema_id) + .await + .map_err(|e| MCPError::internal_error(format!("Failed to retrieve schema: {}", e)))?; + + Ok(json!({ + "schema_id": params.schema_id, + "new_version": schema.version, + "success": true + })) +} + +/// Remove a value from an enum field +/// +/// # MCP Tool Description +/// Remove a value from an enum field's user_values list. Core enum values cannot +/// be removed (protection enforcement). The schema version will be incremented +/// automatically. +/// +/// # Parameters +/// - `schema_id`: ID of the schema to modify +/// - `field_name`: Name of the enum field +/// - `value`: Value to remove from user_values +/// +/// # Returns +/// - `schema_id`: ID of the modified schema +/// - `new_version`: New schema version after removing the value +/// - `success`: true +/// +/// # Errors +/// - `VALIDATION_ERROR`: If trying to remove core value or value doesn't exist +/// - `NODE_NOT_FOUND`: If schema or field doesn't exist +pub async fn handle_remove_schema_enum_value( + schema_service: &Arc, + params: Value, +) -> Result { + let params: RemoveSchemaEnumValueParams = serde_json::from_value(params) + .map_err(|e| MCPError::invalid_params(format!("Invalid parameters: {}", e)))?; + + // Remove enum value via SchemaService + schema_service + .remove_enum_value(¶ms.schema_id, ¶ms.field_name, ¶ms.value) + .await + .map_err(|e| MCPError::validation_error(e.to_string()))?; + + // Get updated schema to return new version + let schema = schema_service + .get_schema(¶ms.schema_id) + .await + .map_err(|e| MCPError::internal_error(format!("Failed to retrieve schema: {}", e)))?; + + Ok(json!({ + "schema_id": params.schema_id, + "new_version": schema.version, + "success": true + })) +} + +/// Get schema definition +/// +/// # MCP Tool Description +/// Retrieve the complete schema definition for a given schema ID. Returns the +/// parsed schema with all fields, protection levels, and metadata. +/// +/// # Parameters +/// - `schema_id`: ID of the schema to retrieve +/// +/// # Returns +/// - `schema`: Complete schema definition object with: +/// - `is_core`: Whether this is a core schema +/// - `version`: Current schema version +/// - `description`: Schema description +/// - `fields`: Array of field definitions with: +/// - `name`: Field name +/// - `type`: Field type +/// - `protection`: Protection level (core, user, system) +/// - `indexed`: Whether field is indexed +/// - Additional field-specific properties +/// +/// # Errors +/// - `NODE_NOT_FOUND`: If schema doesn't exist +pub async fn handle_get_schema_definition( + schema_service: &Arc, + params: Value, +) -> Result { + let params: GetSchemaDefinitionParams = serde_json::from_value(params) + .map_err(|e| MCPError::invalid_params(format!("Invalid parameters: {}", e)))?; + + // Get schema via SchemaService + let schema = schema_service + .get_schema(¶ms.schema_id) + .await + .map_err(|_e| MCPError::node_not_found(¶ms.schema_id))?; + + // Serialize schema to JSON + let schema_json = serde_json::to_value(&schema) + .map_err(|e| MCPError::internal_error(format!("Failed to serialize schema: {}", e)))?; + + Ok(json!({ + "schema": schema_json + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{DatabaseService, NodeService}; + use tempfile::TempDir; + + async fn setup_test_service() -> (Arc, Arc, TempDir) { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let db = DatabaseService::new(db_path).await.unwrap(); + let node_service = Arc::new(NodeService::new(db).unwrap()); + let schema_service = Arc::new(SchemaService::new(node_service.clone())); + (schema_service, node_service, temp_dir) + } + + async fn create_test_schema(node_service: &NodeService) { + // Create a minimal test schema + use crate::models::schema::{SchemaDefinition, SchemaField}; + use crate::models::Node; + + let schema = SchemaDefinition { + is_core: false, + version: 1, + description: "Test schema".to_string(), + fields: vec![SchemaField { + name: "status".to_string(), + field_type: "enum".to_string(), + protection: ProtectionLevel::Core, + core_values: Some(vec!["OPEN".to_string(), "DONE".to_string()]), + user_values: None, + indexed: true, + required: Some(true), + extensible: Some(true), + default: Some(json!("OPEN")), + description: Some("Task status".to_string()), + item_type: None, + }], + }; + + let schema_node = Node { + id: "test_schema".to_string(), + node_type: "schema".to_string(), + content: "Test Schema".to_string(), + parent_id: None, + container_node_id: None, + before_sibling_id: None, + version: 1, + created_at: chrono::Utc::now(), + modified_at: chrono::Utc::now(), + properties: serde_json::to_value(&schema).unwrap(), + embedding_vector: None, + mentions: Vec::new(), + mentioned_by: Vec::new(), + }; + + node_service.create_node(schema_node).await.unwrap(); + } + + #[tokio::test] + async fn test_add_schema_field() { + let (schema_service, node_service, _temp) = setup_test_service().await; + create_test_schema(&node_service).await; + + let params = json!({ + "schema_id": "test_schema", + "field_name": "custom_field", + "field_type": "string", + "indexed": true, + "description": "Custom user field" + }); + + let result = handle_add_schema_field(&schema_service, params) + .await + .unwrap(); + + assert_eq!(result["schema_id"], "test_schema"); + assert_eq!(result["new_version"], 2); + assert_eq!(result["success"], true); + + // Verify field was added + let schema = schema_service.get_schema("test_schema").await.unwrap(); + let field = schema.fields.iter().find(|f| f.name == "custom_field"); + assert!(field.is_some()); + assert_eq!(field.unwrap().protection, ProtectionLevel::User); + } + + #[tokio::test] + async fn test_remove_schema_field() { + let (schema_service, node_service, _temp) = setup_test_service().await; + create_test_schema(&node_service).await; + + // First add a user field + let add_params = json!({ + "schema_id": "test_schema", + "field_name": "temp_field", + "field_type": "string", + "indexed": false + }); + handle_add_schema_field(&schema_service, add_params) + .await + .unwrap(); + + // Then remove it + let remove_params = json!({ + "schema_id": "test_schema", + "field_name": "temp_field" + }); + + let result = handle_remove_schema_field(&schema_service, remove_params) + .await + .unwrap(); + + assert_eq!(result["schema_id"], "test_schema"); + assert_eq!(result["success"], true); + + // Verify field was removed + let schema = schema_service.get_schema("test_schema").await.unwrap(); + let field = schema.fields.iter().find(|f| f.name == "temp_field"); + assert!(field.is_none()); + } + + #[tokio::test] + async fn test_remove_core_field_rejected() { + let (schema_service, node_service, _temp) = setup_test_service().await; + create_test_schema(&node_service).await; + + // Try to remove core field + let params = json!({ + "schema_id": "test_schema", + "field_name": "status" + }); + + let result = handle_remove_schema_field(&schema_service, params).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.message.contains("Cannot remove")); + } + + #[tokio::test] + async fn test_extend_schema_enum() { + let (schema_service, node_service, _temp) = setup_test_service().await; + create_test_schema(&node_service).await; + + // Extend status enum + let params = json!({ + "schema_id": "test_schema", + "field_name": "status", + "value": "BLOCKED" + }); + + let result = handle_extend_schema_enum(&schema_service, params) + .await + .unwrap(); + + assert_eq!(result["schema_id"], "test_schema"); + assert_eq!(result["success"], true); + + // Verify value was added to user_values + let schema = schema_service.get_schema("test_schema").await.unwrap(); + let values = schema.get_enum_values("status").unwrap(); + assert!(values.contains(&"BLOCKED".to_string())); + } + + #[tokio::test] + async fn test_remove_schema_enum_value() { + let (schema_service, node_service, _temp) = setup_test_service().await; + create_test_schema(&node_service).await; + + // First add a value + let add_params = json!({ + "schema_id": "test_schema", + "field_name": "status", + "value": "TEMP_STATUS" + }); + handle_extend_schema_enum(&schema_service, add_params) + .await + .unwrap(); + + // Then remove it + let remove_params = json!({ + "schema_id": "test_schema", + "field_name": "status", + "value": "TEMP_STATUS" + }); + + let result = handle_remove_schema_enum_value(&schema_service, remove_params) + .await + .unwrap(); + + assert_eq!(result["schema_id"], "test_schema"); + assert_eq!(result["success"], true); + + // Verify value was removed + let schema = schema_service.get_schema("test_schema").await.unwrap(); + let values = schema.get_enum_values("status").unwrap(); + assert!(!values.contains(&"TEMP_STATUS".to_string())); + } + + #[tokio::test] + async fn test_remove_core_enum_value_rejected() { + let (schema_service, node_service, _temp) = setup_test_service().await; + create_test_schema(&node_service).await; + + // Try to remove core enum value + let params = json!({ + "schema_id": "test_schema", + "field_name": "status", + "value": "OPEN" + }); + + let result = handle_remove_schema_enum_value(&schema_service, params).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.message.contains("Cannot remove core value")); + } + + #[tokio::test] + async fn test_get_schema_definition() { + let (schema_service, node_service, _temp) = setup_test_service().await; + create_test_schema(&node_service).await; + + let params = json!({ + "schema_id": "test_schema" + }); + + let result = handle_get_schema_definition(&schema_service, params) + .await + .unwrap(); + + assert!(result["schema"].is_object()); + let schema = &result["schema"]; + assert!(!schema["is_core"].as_bool().unwrap()); + assert_eq!(schema["version"].as_u64().unwrap(), 1); + assert!(schema["fields"].is_array()); + assert!(!schema["fields"].as_array().unwrap().is_empty()); + } +} diff --git a/packages/core/src/mcp/handlers/tools.rs b/packages/core/src/mcp/handlers/tools.rs index 7c34866ec..e69485939 100644 --- a/packages/core/src/mcp/handlers/tools.rs +++ b/packages/core/src/mcp/handlers/tools.rs @@ -4,10 +4,10 @@ //! This module centralizes tool discovery and execution according to the //! MCP 2024-11-05 specification. -use crate::mcp::handlers::{markdown, nodes, search}; +use crate::mcp::handlers::{markdown, nodes, schema, search}; use crate::mcp::types::MCPError; use crate::operations::NodeOperations; -use crate::services::NodeEmbeddingService; +use crate::services::{NodeEmbeddingService, SchemaService}; use serde_json::{json, Value}; use std::sync::Arc; @@ -77,6 +77,7 @@ pub fn handle_tools_list(_params: Value) -> Result { /// /// * `node_operations` - Arc reference to NodeOperations for node operations /// * `embedding_service` - Arc reference to NodeEmbeddingService for search +/// * `schema_service` - Arc reference to SchemaService for schema validation /// * `params` - Request parameters containing `name` and `arguments` /// /// # Returns @@ -85,6 +86,7 @@ pub fn handle_tools_list(_params: Value) -> Result { pub async fn handle_tools_call( node_operations: &Arc, embedding_service: &Arc, + schema_service: &Arc, params: Value, ) -> Result { // Extract tool name from params @@ -100,7 +102,9 @@ pub async fn handle_tools_call( // Core Node CRUD "create_node" => nodes::handle_create_node(node_operations, arguments).await, "get_node" => nodes::handle_get_node(node_operations, arguments).await, - "update_node" => nodes::handle_update_node(node_operations, arguments).await, + "update_node" => { + nodes::handle_update_node(node_operations, schema_service, arguments).await + } "delete_node" => nodes::handle_delete_node(node_operations, arguments).await, "query_nodes" => nodes::handle_query_nodes(node_operations, arguments).await, @@ -128,11 +132,26 @@ pub async fn handle_tools_call( // Batch Operations "get_nodes_batch" => nodes::handle_get_nodes_batch(node_operations, arguments).await, - "update_nodes_batch" => nodes::handle_update_nodes_batch(node_operations, arguments).await, + "update_nodes_batch" => { + nodes::handle_update_nodes_batch(node_operations, schema_service, arguments).await + } // Search "search_containers" => search::handle_search_containers(embedding_service, arguments).await, + // Schema Management + "add_schema_field" => schema::handle_add_schema_field(schema_service, arguments).await, + "remove_schema_field" => { + schema::handle_remove_schema_field(schema_service, arguments).await + } + "extend_schema_enum" => schema::handle_extend_schema_enum(schema_service, arguments).await, + "remove_schema_enum_value" => { + schema::handle_remove_schema_enum_value(schema_service, arguments).await + } + "get_schema_definition" => { + schema::handle_get_schema_definition(schema_service, arguments).await + } + _ => { return Err(MCPError::invalid_params(format!( "Unknown tool: {}", @@ -239,7 +258,7 @@ fn get_tool_schemas() -> Value { }, { "name": "update_node", - "description": "Update an existing node's content or properties", + "description": "Update an existing node's content or properties. Note: Core schema fields are protected - they cannot be deleted and enum values must match allowed values. User-defined fields can be freely modified.", "inputSchema": { "type": "object", "properties": { @@ -253,7 +272,7 @@ fn get_tool_schemas() -> Value { }, "properties": { "type": "object", - "description": "Updated properties" + "description": "Updated properties (core fields are protected)" } }, "required": ["node_id"] @@ -548,6 +567,134 @@ fn get_tool_schemas() -> Value { }, "required": ["query"] } + }, + { + "name": "add_schema_field", + "description": "Add a new user-protected field to an existing schema. The schema version will be incremented automatically. Only user-protected fields can be added via MCP.", + "inputSchema": { + "type": "object", + "properties": { + "schema_id": { + "type": "string", + "description": "ID of the schema to modify (matches node_type, e.g. 'task', 'person')" + }, + "field_name": { + "type": "string", + "description": "Name of the new field" + }, + "field_type": { + "type": "string", + "enum": ["string", "number", "boolean", "enum", "array", "object"], + "description": "Type of the field" + }, + "indexed": { + "type": "boolean", + "description": "Whether to index this field for search (default: false)", + "default": false + }, + "required": { + "type": "boolean", + "description": "Whether this field is required (optional)" + }, + "default": { + "description": "Default value for the field (optional)" + }, + "description": { + "type": "string", + "description": "Field description (optional)" + }, + "item_type": { + "type": "string", + "description": "For array fields, the type of items in the array (optional)" + }, + "enum_values": { + "type": "array", + "items": {"type": "string"}, + "description": "For enum fields, the allowed values (added to user_values, optional)" + }, + "extensible": { + "type": "boolean", + "description": "For enum fields, whether users can add more values later (optional)" + } + }, + "required": ["schema_id", "field_name", "field_type"] + } + }, + { + "name": "remove_schema_field", + "description": "Remove a user-protected field from an existing schema. Core and system fields cannot be removed. The schema version will be incremented automatically.", + "inputSchema": { + "type": "object", + "properties": { + "schema_id": { + "type": "string", + "description": "ID of the schema to modify" + }, + "field_name": { + "type": "string", + "description": "Name of the field to remove (must be user-protected)" + } + }, + "required": ["schema_id", "field_name"] + } + }, + { + "name": "extend_schema_enum", + "description": "Add a new value to an enum field's user_values list. The enum field must be marked as extensible. Core enum values cannot be modified. The schema version will be incremented automatically.", + "inputSchema": { + "type": "object", + "properties": { + "schema_id": { + "type": "string", + "description": "ID of the schema to modify" + }, + "field_name": { + "type": "string", + "description": "Name of the enum field to extend" + }, + "value": { + "type": "string", + "description": "New value to add to user_values (will not duplicate if already exists)" + } + }, + "required": ["schema_id", "field_name", "value"] + } + }, + { + "name": "remove_schema_enum_value", + "description": "Remove a value from an enum field's user_values list. Core enum values cannot be removed (protection enforcement). The schema version will be incremented automatically.", + "inputSchema": { + "type": "object", + "properties": { + "schema_id": { + "type": "string", + "description": "ID of the schema to modify" + }, + "field_name": { + "type": "string", + "description": "Name of the enum field" + }, + "value": { + "type": "string", + "description": "Value to remove from user_values (must not be a core value)" + } + }, + "required": ["schema_id", "field_name", "value"] + } + }, + { + "name": "get_schema_definition", + "description": "Retrieve the complete schema definition for a given schema ID. Returns the parsed schema with all fields, protection levels, enum values, and metadata.", + "inputSchema": { + "type": "object", + "properties": { + "schema_id": { + "type": "string", + "description": "ID of the schema to retrieve (e.g. 'task', 'person')" + } + }, + "required": ["schema_id"] + } } ]) } diff --git a/packages/core/src/mcp/handlers/tools_test.rs b/packages/core/src/mcp/handlers/tools_test.rs index 99b6a8763..060d83ba2 100644 --- a/packages/core/src/mcp/handlers/tools_test.rs +++ b/packages/core/src/mcp/handlers/tools_test.rs @@ -4,6 +4,7 @@ use super::*; use crate::operations::NodeOperations; +use crate::services::SchemaService; use serde_json::json; #[test] @@ -19,8 +20,8 @@ fn test_tools_list_returns_all_schemas() { let tools = response["tools"].as_array().unwrap(); - // Verify all 16 tools are present (8 original + 5 hierarchy + 3 batch operations) - assert_eq!(tools.len(), 16); + // Verify all 21 tools are present (16 original + 5 schema management tools) + assert_eq!(tools.len(), 21); // Verify tool names let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); @@ -50,6 +51,13 @@ fn test_tools_list_returns_all_schemas() { // Search assert!(tool_names.contains(&"search_containers")); + + // Schema management tools + assert!(tool_names.contains(&"get_schema_definition")); + assert!(tool_names.contains(&"add_schema_field")); + assert!(tool_names.contains(&"remove_schema_field")); + assert!(tool_names.contains(&"extend_schema_enum")); + assert!(tool_names.contains(&"remove_schema_enum_value")); } #[test] @@ -116,15 +124,20 @@ mod async_integration_tests { use std::sync::Arc; use tempfile::TempDir; - async fn setup_test_services() -> (Arc, Arc, TempDir) { + async fn setup_test_services() -> ( + Arc, + Arc, + Arc, + TempDir, + ) { let temp_dir = TempDir::new().unwrap(); let db_path = temp_dir.path().join("test.db"); let db = DatabaseService::new(db_path).await.unwrap(); // Clone db for both services let db_arc = Arc::new(db); - let node_operations = Arc::new(NodeService::new((*db_arc).clone()).unwrap()); - let node_operations = Arc::new(NodeOperations::new(node_operations)); + let node_service = Arc::new(NodeService::new((*db_arc).clone()).unwrap()); + let node_operations = Arc::new(NodeOperations::new(node_service.clone())); // Create NLP engine for embedding service let mut nlp_engine = EmbeddingService::new(EmbeddingConfig::default()).unwrap(); @@ -132,19 +145,30 @@ mod async_integration_tests { let nlp_engine = Arc::new(nlp_engine); let embedding_service = Arc::new(NodeEmbeddingService::new(nlp_engine, db_arc)); - (node_operations, embedding_service, temp_dir) + + // Create SchemaService + let schema_service = Arc::new(SchemaService::new(node_service)); + + (node_operations, embedding_service, schema_service, temp_dir) } #[tokio::test] async fn test_tools_call_unknown_tool_returns_error() { - let (node_operations, embedding_service, _temp_dir) = setup_test_services().await; + let (node_operations, embedding_service, schema_service, _temp_dir) = + setup_test_services().await; let params = json!({ "name": "unknown_tool", "arguments": {} }); - let result = handle_tools_call(&node_operations, &embedding_service, params).await; + let result = handle_tools_call( + &node_operations, + &embedding_service, + &schema_service, + params, + ) + .await; // Should return Err with invalid params error assert!(result.is_err()); @@ -155,13 +179,20 @@ mod async_integration_tests { #[tokio::test] async fn test_tools_call_missing_name_parameter() { - let (node_operations, embedding_service, _temp_dir) = setup_test_services().await; + let (node_operations, embedding_service, schema_service, _temp_dir) = + setup_test_services().await; let params = json!({ "arguments": {"content": "test"} }); - let result = handle_tools_call(&node_operations, &embedding_service, params).await; + let result = handle_tools_call( + &node_operations, + &embedding_service, + &schema_service, + params, + ) + .await; // Should return Err with invalid params error assert!(result.is_err()); @@ -172,7 +203,8 @@ mod async_integration_tests { #[tokio::test] async fn test_tools_call_create_node_success() { - let (node_operations, embedding_service, _temp_dir) = setup_test_services().await; + let (node_operations, embedding_service, schema_service, _temp_dir) = + setup_test_services().await; let params = json!({ "name": "create_node", @@ -182,7 +214,13 @@ mod async_integration_tests { } }); - let result = handle_tools_call(&node_operations, &embedding_service, params).await; + let result = handle_tools_call( + &node_operations, + &embedding_service, + &schema_service, + params, + ) + .await; // Should return Ok with MCP spec-compliant response assert!(result.is_ok(), "tools/call should succeed"); @@ -212,7 +250,8 @@ mod async_integration_tests { #[tokio::test] async fn test_tools_call_get_node_not_found_returns_error_response() { - let (node_operations, embedding_service, _temp_dir) = setup_test_services().await; + let (node_operations, embedding_service, schema_service, _temp_dir) = + setup_test_services().await; let params = json!({ "name": "get_node", @@ -221,7 +260,13 @@ mod async_integration_tests { } }); - let result = handle_tools_call(&node_operations, &embedding_service, params).await; + let result = handle_tools_call( + &node_operations, + &embedding_service, + &schema_service, + params, + ) + .await; // Should return Ok with isError=true (per MCP spec, tool errors are not JSON-RPC errors) assert!(result.is_ok()); @@ -240,7 +285,8 @@ mod async_integration_tests { #[tokio::test] async fn test_tools_call_query_nodes_success() { - let (node_operations, embedding_service, _temp_dir) = setup_test_services().await; + let (node_operations, embedding_service, schema_service, _temp_dir) = + setup_test_services().await; // First create a node let create_params = json!({ @@ -250,9 +296,14 @@ mod async_integration_tests { "content": "Searchable content" } }); - handle_tools_call(&node_operations, &embedding_service, create_params) - .await - .unwrap(); + handle_tools_call( + &node_operations, + &embedding_service, + &schema_service, + create_params, + ) + .await + .unwrap(); // Now query for nodes let query_params = json!({ @@ -263,7 +314,13 @@ mod async_integration_tests { } }); - let result = handle_tools_call(&node_operations, &embedding_service, query_params).await; + let result = handle_tools_call( + &node_operations, + &embedding_service, + &schema_service, + query_params, + ) + .await; assert!(result.is_ok()); let response = result.unwrap(); @@ -281,14 +338,21 @@ mod async_integration_tests { #[tokio::test] async fn test_tools_call_with_missing_arguments_uses_default() { - let (node_operations, embedding_service, _temp_dir) = setup_test_services().await; + let (node_operations, embedding_service, schema_service, _temp_dir) = + setup_test_services().await; // Call without arguments field let params = json!({ "name": "query_nodes" }); - let result = handle_tools_call(&node_operations, &embedding_service, params).await; + let result = handle_tools_call( + &node_operations, + &embedding_service, + &schema_service, + params, + ) + .await; // Should work with default empty arguments assert!(result.is_ok()); diff --git a/packages/core/src/mcp/server.rs b/packages/core/src/mcp/server.rs index e2ca5683f..351f23fa6 100644 --- a/packages/core/src/mcp/server.rs +++ b/packages/core/src/mcp/server.rs @@ -8,7 +8,7 @@ use crate::mcp::types::{MCPError, MCPNotification, MCPRequest, MCPResponse}; use crate::operations::NodeOperations; -use crate::services::NodeEmbeddingService; +use crate::services::{NodeEmbeddingService, SchemaService}; use axum::{ body::Body, extract::State, @@ -38,6 +38,7 @@ pub enum McpTransport { pub struct McpServices { pub node_operations: Arc, pub embedding_service: Arc, + pub schema_service: Arc, } /// Server state tracking initialization status @@ -541,6 +542,7 @@ async fn handle_request( crate::mcp::handlers::tools::handle_tools_call( &services.node_operations, &services.embedding_service, + &services.schema_service, request.params, ) .await @@ -672,12 +674,14 @@ mod tests { let db = DatabaseService::new(db_path).await.unwrap(); let db_arc = Arc::new(db); let node_service = Arc::new(NodeService::new((*db_arc).clone()).unwrap()); - let node_operations = Arc::new(NodeOperations::new(node_service)); + let node_operations = Arc::new(NodeOperations::new(node_service.clone())); let embedding_service = Arc::new(NodeEmbeddingService::new_with_defaults(db_arc).unwrap()); + let schema_service = Arc::new(SchemaService::new(node_service)); McpServices { node_operations, embedding_service, + schema_service, } } diff --git a/packages/core/src/models/mod.rs b/packages/core/src/models/mod.rs index de88bd348..c615c2033 100644 --- a/packages/core/src/models/mod.rs +++ b/packages/core/src/models/mod.rs @@ -9,10 +9,12 @@ //! `properties` field of the universal `nodes` table. mod node; +pub mod schema; pub mod time; pub use node::{ DeleteResult, FilterOperator, Node, NodeFilter, NodeQuery, NodeUpdate, OrderBy, PropertyFilter, ValidationError, }; +pub use schema::{ProtectionLevel, SchemaDefinition, SchemaField}; pub use time::{SystemTimeProvider, TimeProvider}; diff --git a/packages/core/src/models/schema.rs b/packages/core/src/models/schema.rs new file mode 100644 index 000000000..5391b8d2b --- /dev/null +++ b/packages/core/src/models/schema.rs @@ -0,0 +1,310 @@ +//! Schema Management Types +//! +//! This module contains data structures for managing user-defined entity schemas +//! in NodeSpace. Schemas are stored as regular nodes with `node_type = 'schema'` +//! and follow the Pure JSON schema-as-node pattern. +//! +//! ## Schema Protection Levels +//! +//! - `Core`: Cannot be modified or deleted (UI components depend on these fields) +//! - `User`: Fully modifiable/deletable by users +//! - `System`: Auto-managed internal fields, read-only +//! +//! ## Example Schema Node +//! +//! ```json +//! { +//! "id": "task", +//! "node_type": "schema", +//! "content": "Task", +//! "properties": { +//! "is_core": true, +//! "version": 2, +//! "description": "Task tracking schema", +//! "fields": [ +//! { +//! "name": "status", +//! "type": "enum", +//! "protection": "core", +//! "core_values": ["OPEN", "IN_PROGRESS", "DONE"], +//! "user_values": ["BLOCKED"], +//! "extensible": true, +//! "indexed": true, +//! "required": true, +//! "default": "OPEN" +//! } +//! ] +//! } +//! } +//! ``` + +use serde::{Deserialize, Serialize}; + +/// Protection level for schema fields +/// +/// Determines whether a field can be modified or deleted by users. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ProtectionLevel { + /// Core fields that cannot be modified or deleted + /// + /// These are fields that UI components or core functionality depend on. + /// Examples: task.status, person.name + Core, + + /// User-defined fields that can be freely modified or deleted + /// + /// These are fields added by users for custom properties. + User, + + /// System-managed fields that are read-only + /// + /// These are auto-managed internal fields like timestamps. + /// Reserved for future use. + System, +} + +/// Definition of a single field in a schema +/// +/// Supports various field types including primitives, enums, arrays, and objects. +/// Enum fields can have protected core values and user-extensible values. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SchemaField { + /// Field name (must be unique within schema) + pub name: String, + + /// Field type (e.g., "string", "number", "boolean", "enum", "array", "object") + #[serde(rename = "type")] + pub field_type: String, + + /// Protection level determining mutability + pub protection: ProtectionLevel, + + /// Protected enum values (cannot be removed) - enum fields only + #[serde(skip_serializing_if = "Option::is_none")] + pub core_values: Option>, + + /// User-extensible enum values (can be added/removed) - enum fields only + #[serde(skip_serializing_if = "Option::is_none")] + pub user_values: Option>, + + /// Whether this field should be indexed for faster queries + pub indexed: bool, + + /// Whether this field is required (cannot be null/undefined) + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option, + + /// Whether enum values can be extended by users + #[serde(skip_serializing_if = "Option::is_none")] + pub extensible: Option, + + /// Default value for the field + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + + /// Human-readable description of the field + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Type of items in array fields + #[serde(skip_serializing_if = "Option::is_none")] + pub item_type: Option, +} + +/// Complete schema definition for an entity type +/// +/// Stored in the `properties` field of schema nodes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SchemaDefinition { + /// Whether this is a core schema (shipped with NodeSpace) + pub is_core: bool, + + /// Schema version number (increments on any change) + /// + /// Used for lazy migration: nodes track which version validated them, + /// and are automatically upgraded when accessed if schema version is newer. + pub version: u32, + + /// Human-readable description of this schema + pub description: String, + + /// List of fields in this schema + pub fields: Vec, +} + +impl SchemaDefinition { + /// Get all valid values for an enum field (core + user values combined) + /// + /// Returns `None` if the field doesn't exist or isn't an enum. + /// + /// # Example + /// + /// ```rust + /// let schema = get_task_schema(); + /// let status_values = schema.get_enum_values("status"); + /// // Returns: Some(["OPEN", "IN_PROGRESS", "DONE", "BLOCKED"]) + /// ``` + pub fn get_enum_values(&self, field_name: &str) -> Option> { + let field = self.fields.iter().find(|f| f.name == field_name)?; + + // Only return values for enum fields + if field.field_type != "enum" { + return None; + } + + let mut values = Vec::new(); + if let Some(core_vals) = &field.core_values { + values.extend(core_vals.clone()); + } + if let Some(user_vals) = &field.user_values { + values.extend(user_vals.clone()); + } + + Some(values) + } + + /// Check if a field can be deleted based on its protection level + /// + /// Only `User` protected fields can be deleted. + /// + /// # Example + /// + /// ```rust + /// let schema = get_task_schema(); + /// assert!(!schema.can_delete_field("status")); // Core field + /// assert!(schema.can_delete_field("priority")); // User field + /// ``` + pub fn can_delete_field(&self, field_name: &str) -> bool { + if let Some(field) = self.fields.iter().find(|f| f.name == field_name) { + field.protection == ProtectionLevel::User + } else { + false + } + } + + /// Check if a field can be modified based on its protection level + /// + /// Only `User` protected fields can be modified (type changes, etc.). + /// Core/System fields are immutable. + pub fn can_modify_field(&self, field_name: &str) -> bool { + if let Some(field) = self.fields.iter().find(|f| f.name == field_name) { + field.protection == ProtectionLevel::User + } else { + false + } + } + + /// Get a field by name + pub fn get_field(&self, field_name: &str) -> Option<&SchemaField> { + self.fields.iter().find(|f| f.name == field_name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn create_test_schema() -> SchemaDefinition { + SchemaDefinition { + is_core: true, + version: 1, + description: "Test schema".to_string(), + fields: vec![ + SchemaField { + name: "status".to_string(), + field_type: "enum".to_string(), + protection: ProtectionLevel::Core, + core_values: Some(vec!["OPEN".to_string(), "DONE".to_string()]), + user_values: Some(vec!["BLOCKED".to_string()]), + indexed: true, + required: Some(true), + extensible: Some(true), + default: Some(json!("OPEN")), + description: Some("Task status".to_string()), + item_type: None, + }, + SchemaField { + name: "priority".to_string(), + field_type: "number".to_string(), + protection: ProtectionLevel::User, + core_values: None, + user_values: None, + indexed: false, + required: Some(false), + extensible: None, + default: Some(json!(0)), + description: Some("Task priority".to_string()), + item_type: None, + }, + ], + } + } + + #[test] + fn test_get_enum_values() { + let schema = create_test_schema(); + + let values = schema.get_enum_values("status").unwrap(); + assert_eq!(values.len(), 3); + assert!(values.contains(&"OPEN".to_string())); + assert!(values.contains(&"DONE".to_string())); + assert!(values.contains(&"BLOCKED".to_string())); + + assert!(schema.get_enum_values("priority").is_none()); + assert!(schema.get_enum_values("nonexistent").is_none()); + } + + #[test] + fn test_can_delete_field() { + let schema = create_test_schema(); + + assert!(!schema.can_delete_field("status")); // Core field + assert!(schema.can_delete_field("priority")); // User field + assert!(!schema.can_delete_field("nonexistent")); // Doesn't exist + } + + #[test] + fn test_can_modify_field() { + let schema = create_test_schema(); + + assert!(!schema.can_modify_field("status")); // Core field + assert!(schema.can_modify_field("priority")); // User field + assert!(!schema.can_modify_field("nonexistent")); // Doesn't exist + } + + #[test] + fn test_serialization() { + let schema = create_test_schema(); + let json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(json["is_core"], true); + assert_eq!(json["version"], 1); + assert_eq!(json["fields"][0]["name"], "status"); + assert_eq!(json["fields"][0]["protection"], "core"); + } + + #[test] + fn test_deserialization() { + let json = json!({ + "is_core": true, + "version": 2, + "description": "Task schema", + "fields": [ + { + "name": "status", + "type": "enum", + "protection": "core", + "core_values": ["OPEN", "DONE"], + "indexed": true + } + ] + }); + + let schema: SchemaDefinition = serde_json::from_value(json).unwrap(); + assert_eq!(schema.version, 2); + assert_eq!(schema.fields.len(), 1); + assert_eq!(schema.fields[0].protection, ProtectionLevel::Core); + } +} diff --git a/packages/core/src/services/migration_registry.rs b/packages/core/src/services/migration_registry.rs new file mode 100644 index 000000000..95561d6a1 --- /dev/null +++ b/packages/core/src/services/migration_registry.rs @@ -0,0 +1,493 @@ +//! Schema Migration Registry +//! +//! This module provides infrastructure for managing schema migration transforms +//! using a lazy migration strategy. Migrations are applied on-demand when nodes +//! are accessed, rather than in bulk operations. +//! +//! ## Architecture +//! +//! - **Lazy Migration**: Nodes upgrade when accessed, not via bulk operations +//! - **Version Tracking**: Each node tracks `_schema_version` in properties +//! - **Transform Functions**: Pure functions that upgrade nodes (v1→v2, v2→v3) +//! - **Migration Chaining**: Automatically chains transforms (v1→v2→v3) +//! - **Desktop-Safe**: No coordinated migrations across installations +//! +//! ## Example Usage +//! +//! ```no_run +//! # use nodespace_core::services::migration_registry::MigrationRegistry; +//! # use nodespace_core::models::Node; +//! # use nodespace_core::services::NodeServiceError; +//! # use serde_json::json; +//! # fn main() -> Result<(), NodeServiceError> { +//! let mut registry = MigrationRegistry::new(); +//! +//! // Register a migration transform +//! registry.register_migration( +//! "task", // schema ID +//! 1, // from version +//! 2, // to version +//! |node| { +//! // Transform the node (e.g., add new field with default value) +//! let mut node = node.clone(); +//! if let Some(obj) = node.properties.as_object_mut() { +//! obj.insert("priority".to_string(), json!(0)); +//! obj.insert("_schema_version".to_string(), json!(2)); +//! } +//! Ok(node) +//! } +//! ); +//! +//! // Apply migrations to upgrade a node +//! let mut old_node = Node::new("task".to_string(), "Test".to_string(), None, json!({ +//! "_schema_version": 1 +//! })); +//! +//! let upgraded = registry.apply_migrations(&old_node, 2)?; +//! assert_eq!(upgraded.properties["_schema_version"], 2); +//! # Ok(()) +//! # } +//! ``` + +use crate::models::Node; +use crate::services::NodeServiceError; +use std::collections::HashMap; + +/// Type alias for migration transform functions +/// +/// Transform functions are pure: they take a node and return a transformed node. +/// They should not have side effects and must update the node's `_schema_version`. +/// +/// # Arguments +/// +/// * `node` - The node to transform (at version N) +/// +/// # Returns +/// +/// The transformed node (at version N+1), or an error if migration fails +pub type MigrationTransform = fn(&Node) -> Result; + +/// Registry for schema migration transforms +/// +/// Stores migration functions keyed by (schema_id, from_version, to_version) +/// and provides methods to apply migrations with automatic chaining. +/// +/// # Example +/// +/// ```no_run +/// # use nodespace_core::services::migration_registry::MigrationRegistry; +/// let mut registry = MigrationRegistry::new(); +/// +/// // Register migrations +/// registry.register_migration("task", 1, 2, task_v1_to_v2); +/// registry.register_migration("task", 2, 3, task_v2_to_v3); +/// +/// // Apply chained migrations (v1→v2→v3) +/// let upgraded = registry.apply_migrations(&node, 3)?; +/// # fn task_v1_to_v2(node: &nodespace_core::models::Node) -> Result { Ok(node.clone()) } +/// # fn task_v2_to_v3(node: &nodespace_core::models::Node) -> Result { Ok(node.clone()) } +/// # Ok::<(), nodespace_core::services::NodeServiceError>(()) +/// ``` +pub struct MigrationRegistry { + /// Map of (schema_id, from_version, to_version) → transform function + migrations: HashMap<(String, u32, u32), MigrationTransform>, +} + +impl MigrationRegistry { + /// Create a new empty migration registry + pub fn new() -> Self { + Self { + migrations: HashMap::new(), + } + } + + /// Register a migration transform for a schema version upgrade + /// + /// # Arguments + /// + /// * `schema_id` - The schema identifier (e.g., "task", "person") + /// * `from_version` - The source schema version + /// * `to_version` - The target schema version + /// * `transform` - The migration function to apply + /// + /// # Example + /// + /// ```no_run + /// # use nodespace_core::services::migration_registry::MigrationRegistry; + /// # use nodespace_core::models::Node; + /// # use nodespace_core::services::NodeServiceError; + /// # use serde_json::json; + /// let mut registry = MigrationRegistry::new(); + /// + /// registry.register_migration("task", 1, 2, |node| { + /// let mut node = node.clone(); + /// if let Some(obj) = node.properties.as_object_mut() { + /// // Add new field with default value + /// obj.insert("priority".to_string(), json!(0)); + /// obj.insert("_schema_version".to_string(), json!(2)); + /// } + /// Ok(node) + /// }); + /// ``` + pub fn register_migration( + &mut self, + schema_id: impl Into, + from_version: u32, + to_version: u32, + transform: MigrationTransform, + ) { + let key = (schema_id.into(), from_version, to_version); + self.migrations.insert(key, transform); + } + + /// Apply migrations to upgrade a node from its current version to a target version + /// + /// This method automatically chains migrations if needed. For example, if a node + /// is at version 1 and needs to reach version 3, it will apply v1→v2, then v2→v3. + /// + /// # Arguments + /// + /// * `node` - The node to migrate (must have `_schema_version` in properties) + /// * `target_version` - The target schema version + /// + /// # Returns + /// + /// The migrated node at the target version + /// + /// # Errors + /// + /// - `SerializationError`: Node doesn't have `_schema_version` property + /// - `SerializationError`: Missing migration path from current to target version + /// - Any error returned by migration transform functions + /// + /// # Example + /// + /// ```no_run + /// # use nodespace_core::services::migration_registry::MigrationRegistry; + /// # use nodespace_core::models::Node; + /// # use serde_json::json; + /// # let mut registry = MigrationRegistry::new(); + /// # let node = Node::new("task".to_string(), "Test".to_string(), None, json!({"_schema_version": 1})); + /// // Automatically chains v1→v2→v3 + /// let upgraded = registry.apply_migrations(&node, 3)?; + /// assert_eq!(upgraded.properties["_schema_version"], 3); + /// # Ok::<(), nodespace_core::services::NodeServiceError>(()) + /// ``` + pub fn apply_migrations( + &self, + node: &Node, + target_version: u32, + ) -> Result { + // Get current version from node properties + let current_version = node + .properties + .get("_schema_version") + .and_then(|v| v.as_u64()) + .ok_or_else(|| { + NodeServiceError::SerializationError( + "Node missing _schema_version property".to_string(), + ) + })? as u32; + + // No migration needed if already at target version + if current_version == target_version { + return Ok(node.clone()); + } + + // No migration needed if already newer than target + if current_version > target_version { + return Ok(node.clone()); + } + + // Apply migration chain: current → current+1 → current+2 → ... → target + let mut migrated_node = node.clone(); + let mut current = current_version; + + while current < target_version { + let next_version = current + 1; + let key = (node.node_type.clone(), current, next_version); + + // Find migration for this step + let transform = self.migrations.get(&key).ok_or_else(|| { + NodeServiceError::SerializationError(format!( + "No migration found for {} from version {} to {}", + node.node_type, current, next_version + )) + })?; + + // Apply transform + migrated_node = transform(&migrated_node)?; + + // Verify version was updated + let new_version = migrated_node + .properties + .get("_schema_version") + .and_then(|v| v.as_u64()) + .ok_or_else(|| { + NodeServiceError::SerializationError(format!( + "Migration {} v{}→v{} did not set _schema_version", + node.node_type, current, next_version + )) + })? as u32; + + if new_version != next_version { + return Err(NodeServiceError::SerializationError(format!( + "Migration {} v{}→v{} set version to {} instead of {}", + node.node_type, current, next_version, new_version, next_version + ))); + } + + current = next_version; + } + + Ok(migrated_node) + } + + /// Check if a migration path exists from one version to another + /// + /// This checks if all intermediate migrations are registered. + /// + /// # Arguments + /// + /// * `schema_id` - The schema identifier + /// * `from_version` - Starting version + /// * `to_version` - Target version + /// + /// # Returns + /// + /// `true` if all migration steps exist, `false` otherwise + pub fn has_migration_path(&self, schema_id: &str, from_version: u32, to_version: u32) -> bool { + if from_version >= to_version { + return true; + } + + let mut current = from_version; + while current < to_version { + let next = current + 1; + let key = (schema_id.to_string(), current, next); + if !self.migrations.contains_key(&key) { + return false; + } + current = next; + } + + true + } + + /// Get the number of registered migrations + pub fn migration_count(&self) -> usize { + self.migrations.len() + } +} + +impl Default for MigrationRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // Helper to create a test node with version + fn create_node_with_version(version: u32) -> Node { + Node::new( + "task".to_string(), + "Test task".to_string(), + None, + json!({ + "_schema_version": version, + "status": "OPEN" + }), + ) + } + + // Migration v1→v2: Add priority field + fn task_v1_to_v2(node: &Node) -> Result { + let mut node = node.clone(); + if let Some(obj) = node.properties.as_object_mut() { + obj.insert("priority".to_string(), json!(0)); + obj.insert("_schema_version".to_string(), json!(2)); + } + Ok(node) + } + + // Migration v2→v3: Add assignee field + fn task_v2_to_v3(node: &Node) -> Result { + let mut node = node.clone(); + if let Some(obj) = node.properties.as_object_mut() { + obj.insert("assignee".to_string(), json!(null)); + obj.insert("_schema_version".to_string(), json!(3)); + } + Ok(node) + } + + // Migration v3→v4: Rename status values + fn task_v3_to_v4(node: &Node) -> Result { + let mut node = node.clone(); + if let Some(obj) = node.properties.as_object_mut() { + // Rename OPEN → TODO + if let Some(status) = obj.get("status") { + if status == "OPEN" { + obj.insert("status".to_string(), json!("TODO")); + } + } + obj.insert("_schema_version".to_string(), json!(4)); + } + Ok(node) + } + + #[test] + fn test_register_migration() { + let mut registry = MigrationRegistry::new(); + assert_eq!(registry.migration_count(), 0); + + registry.register_migration("task", 1, 2, task_v1_to_v2); + assert_eq!(registry.migration_count(), 1); + } + + #[test] + fn test_single_migration() { + let mut registry = MigrationRegistry::new(); + registry.register_migration("task", 1, 2, task_v1_to_v2); + + let node = create_node_with_version(1); + let migrated = registry.apply_migrations(&node, 2).unwrap(); + + assert_eq!(migrated.properties["_schema_version"], 2); + assert_eq!(migrated.properties["priority"], 0); + assert_eq!(migrated.properties["status"], "OPEN"); // Original field preserved + } + + #[test] + fn test_no_migration_needed_same_version() { + let mut registry = MigrationRegistry::new(); + registry.register_migration("task", 1, 2, task_v1_to_v2); + + let node = create_node_with_version(2); + let migrated = registry.apply_migrations(&node, 2).unwrap(); + + // Should return clone without changes + assert_eq!(migrated.properties["_schema_version"], 2); + assert!(migrated.properties.get("priority").is_none()); // v2 field not added + } + + #[test] + fn test_no_migration_needed_newer_version() { + let mut registry = MigrationRegistry::new(); + registry.register_migration("task", 1, 2, task_v1_to_v2); + + let node = create_node_with_version(3); + let migrated = registry.apply_migrations(&node, 2).unwrap(); + + // Should return clone without downgrading + assert_eq!(migrated.properties["_schema_version"], 3); + } + + #[test] + fn test_migration_chaining() { + let mut registry = MigrationRegistry::new(); + registry.register_migration("task", 1, 2, task_v1_to_v2); + registry.register_migration("task", 2, 3, task_v2_to_v3); + registry.register_migration("task", 3, 4, task_v3_to_v4); + + let node = create_node_with_version(1); + let migrated = registry.apply_migrations(&node, 4).unwrap(); + + // All migrations should be applied + assert_eq!(migrated.properties["_schema_version"], 4); + assert_eq!(migrated.properties["priority"], 0); // Added in v1→v2 + assert_eq!(migrated.properties["assignee"], json!(null)); // Added in v2→v3 + assert_eq!(migrated.properties["status"], "TODO"); // Changed in v3→v4 + } + + #[test] + fn test_partial_migration_chain() { + let mut registry = MigrationRegistry::new(); + registry.register_migration("task", 1, 2, task_v1_to_v2); + registry.register_migration("task", 2, 3, task_v2_to_v3); + registry.register_migration("task", 3, 4, task_v3_to_v4); + + // Start from v2, go to v4 + let node = create_node_with_version(2); + let migrated = registry.apply_migrations(&node, 4).unwrap(); + + assert_eq!(migrated.properties["_schema_version"], 4); + assert!(migrated.properties.get("priority").is_none()); // Not added (started at v2) + assert_eq!(migrated.properties["assignee"], json!(null)); // Added in v2→v3 + assert_eq!(migrated.properties["status"], "TODO"); // Changed in v3→v4 + } + + #[test] + fn test_missing_migration_error() { + let mut registry = MigrationRegistry::new(); + registry.register_migration("task", 1, 2, task_v1_to_v2); + // Missing v2→v3 migration + + let node = create_node_with_version(1); + let result = registry.apply_migrations(&node, 3); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, NodeServiceError::SerializationError(_))); + } + + #[test] + fn test_missing_schema_version_error() { + let registry = MigrationRegistry::new(); + + let node = Node::new( + "task".to_string(), + "Test".to_string(), + None, + json!({"status": "OPEN"}), // No _schema_version + ); + + let result = registry.apply_migrations(&node, 2); + assert!(result.is_err()); + } + + #[test] + fn test_has_migration_path() { + let mut registry = MigrationRegistry::new(); + registry.register_migration("task", 1, 2, task_v1_to_v2); + registry.register_migration("task", 2, 3, task_v2_to_v3); + + assert!(registry.has_migration_path("task", 1, 2)); + assert!(registry.has_migration_path("task", 1, 3)); + assert!(!registry.has_migration_path("task", 1, 4)); // Missing v3→v4 + assert!(registry.has_migration_path("task", 2, 2)); // Same version + } + + #[test] + fn test_migration_preserves_other_fields() { + let mut registry = MigrationRegistry::new(); + registry.register_migration("task", 1, 2, task_v1_to_v2); + + let node = Node::new( + "task".to_string(), + "Test task".to_string(), + None, + json!({ + "_schema_version": 1, + "status": "OPEN", + "custom_field": "custom_value", + "nested": { + "deep": "value" + } + }), + ); + + let migrated = registry.apply_migrations(&node, 2).unwrap(); + + // New field added + assert_eq!(migrated.properties["priority"], 0); + + // All original fields preserved + assert_eq!(migrated.properties["status"], "OPEN"); + assert_eq!(migrated.properties["custom_field"], "custom_value"); + assert_eq!(migrated.properties["nested"]["deep"], "value"); + } +} diff --git a/packages/core/src/services/migrations/mod.rs b/packages/core/src/services/migrations/mod.rs new file mode 100644 index 000000000..3d6bf638f --- /dev/null +++ b/packages/core/src/services/migrations/mod.rs @@ -0,0 +1,23 @@ +//! Schema Migration Implementations +//! +//! This module contains concrete migration transforms for different entity schemas. +//! Each submodule provides migration functions for a specific entity type. +//! +//! ## Available Migrations +//! +//! - `task` - Task schema migrations (v1→v2, v2→v3, v3→v4) +//! +//! ## Example Usage +//! +//! ```no_run +//! # use nodespace_core::services::migration_registry::MigrationRegistry; +//! # use nodespace_core::services::migrations; +//! let mut registry = MigrationRegistry::new(); +//! +//! // Register all migrations for all schemas +//! migrations::task::register_migrations(&mut registry); +//! // migrations::person::register_migrations(&mut registry); // Future +//! // migrations::project::register_migrations(&mut registry); // Future +//! ``` + +pub mod task; diff --git a/packages/core/src/services/migrations/task.rs b/packages/core/src/services/migrations/task.rs new file mode 100644 index 000000000..e131f191e --- /dev/null +++ b/packages/core/src/services/migrations/task.rs @@ -0,0 +1,307 @@ +//! Task Schema Migrations +//! +//! This module contains migration transforms for the Task entity schema. +//! Each migration upgrades nodes from one schema version to the next. +//! +//! ## Migration History +//! +//! - **v1 → v2**: Added `priority` field (default: 0) +//! - **v2 → v3**: Added `assignee` field (default: null) +//! - **v3 → v4**: Renamed status values (OPEN → TODO, IN_PROGRESS → DOING) +//! +//! ## Usage +//! +//! ```no_run +//! # use nodespace_core::services::migration_registry::MigrationRegistry; +//! # use nodespace_core::services::migrations::task; +//! let mut registry = MigrationRegistry::new(); +//! +//! // Register all task migrations +//! task::register_migrations(&mut registry); +//! ``` + +use crate::models::Node; +use crate::services::migration_registry::MigrationRegistry; +use crate::services::NodeServiceError; +use serde_json::json; + +/// Register all task schema migrations +/// +/// This function registers all migration transforms for the task schema +/// with the provided migration registry. +/// +/// # Arguments +/// +/// * `registry` - The migration registry to register transforms with +/// +/// # Example +/// +/// ```no_run +/// # use nodespace_core::services::migration_registry::MigrationRegistry; +/// # use nodespace_core::services::migrations::task; +/// let mut registry = MigrationRegistry::new(); +/// task::register_migrations(&mut registry); +/// ``` +pub fn register_migrations(registry: &mut MigrationRegistry) { + registry.register_migration("task", 1, 2, migrate_v1_to_v2); + registry.register_migration("task", 2, 3, migrate_v2_to_v3); + registry.register_migration("task", 3, 4, migrate_v3_to_v4); +} + +/// Migrate task from v1 to v2: Add priority field +/// +/// Adds a `priority` field with default value 0 to all task nodes. +/// +/// # Schema Changes +/// +/// - Added: `priority` (number, default: 0) +/// +/// # Example +/// +/// **Before (v1):** +/// ```json +/// { +/// "_schema_version": 1, +/// "status": "OPEN" +/// } +/// ``` +/// +/// **After (v2):** +/// ```json +/// { +/// "_schema_version": 2, +/// "status": "OPEN", +/// "priority": 0 +/// } +/// ``` +fn migrate_v1_to_v2(node: &Node) -> Result { + let mut node = node.clone(); + + if let Some(obj) = node.properties.as_object_mut() { + // Add priority field with default value + obj.insert("priority".to_string(), json!(0)); + + // Update schema version + obj.insert("_schema_version".to_string(), json!(2)); + } + + Ok(node) +} + +/// Migrate task from v2 to v3: Add assignee field +/// +/// Adds an `assignee` field (nullable) to track task ownership. +/// +/// # Schema Changes +/// +/// - Added: `assignee` (string | null, default: null) +/// +/// # Example +/// +/// **Before (v2):** +/// ```json +/// { +/// "_schema_version": 2, +/// "status": "OPEN", +/// "priority": 0 +/// } +/// ``` +/// +/// **After (v3):** +/// ```json +/// { +/// "_schema_version": 3, +/// "status": "OPEN", +/// "priority": 0, +/// "assignee": null +/// } +/// ``` +fn migrate_v2_to_v3(node: &Node) -> Result { + let mut node = node.clone(); + + if let Some(obj) = node.properties.as_object_mut() { + // Add assignee field (nullable) + obj.insert("assignee".to_string(), json!(null)); + + // Update schema version + obj.insert("_schema_version".to_string(), json!(3)); + } + + Ok(node) +} + +/// Migrate task from v3 to v4: Rename status values +/// +/// Updates status enum values to match new naming convention: +/// - OPEN → TODO +/// - IN_PROGRESS → DOING +/// - DONE → COMPLETED +/// +/// # Schema Changes +/// +/// - Modified: `status` enum values renamed +/// +/// # Example +/// +/// **Before (v3):** +/// ```json +/// { +/// "_schema_version": 3, +/// "status": "OPEN", +/// "priority": 0, +/// "assignee": null +/// } +/// ``` +/// +/// **After (v4):** +/// ```json +/// { +/// "_schema_version": 4, +/// "status": "TODO", +/// "priority": 0, +/// "assignee": null +/// } +/// ``` +fn migrate_v3_to_v4(node: &Node) -> Result { + let mut node = node.clone(); + + if let Some(obj) = node.properties.as_object_mut() { + // Rename status values + if let Some(status) = obj.get("status").and_then(|v| v.as_str()) { + let new_status = match status { + "OPEN" => "TODO", + "IN_PROGRESS" => "DOING", + "DONE" => "COMPLETED", + other => other, // Preserve unknown values + }; + obj.insert("status".to_string(), json!(new_status)); + } + + // Update schema version + obj.insert("_schema_version".to_string(), json!(4)); + } + + Ok(node) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn create_task_node(version: u32, status: &str) -> Node { + Node::new( + "task".to_string(), + "Test task".to_string(), + None, + json!({ + "_schema_version": version, + "status": status + }), + ) + } + + #[test] + fn test_migrate_v1_to_v2_adds_priority() { + let node = create_task_node(1, "OPEN"); + let migrated = migrate_v1_to_v2(&node).unwrap(); + + assert_eq!(migrated.properties["_schema_version"], 2); + assert_eq!(migrated.properties["priority"], 0); + assert_eq!(migrated.properties["status"], "OPEN"); // Preserved + } + + #[test] + fn test_migrate_v2_to_v3_adds_assignee() { + let mut node = create_task_node(2, "OPEN"); + if let Some(obj) = node.properties.as_object_mut() { + obj.insert("priority".to_string(), json!(5)); + } + + let migrated = migrate_v2_to_v3(&node).unwrap(); + + assert_eq!(migrated.properties["_schema_version"], 3); + assert_eq!(migrated.properties["assignee"], json!(null)); + assert_eq!(migrated.properties["priority"], 5); // Preserved + } + + #[test] + fn test_migrate_v3_to_v4_renames_status_open() { + let node = create_task_node(3, "OPEN"); + let migrated = migrate_v3_to_v4(&node).unwrap(); + + assert_eq!(migrated.properties["_schema_version"], 4); + assert_eq!(migrated.properties["status"], "TODO"); + } + + #[test] + fn test_migrate_v3_to_v4_renames_status_in_progress() { + let node = create_task_node(3, "IN_PROGRESS"); + let migrated = migrate_v3_to_v4(&node).unwrap(); + + assert_eq!(migrated.properties["_schema_version"], 4); + assert_eq!(migrated.properties["status"], "DOING"); + } + + #[test] + fn test_migrate_v3_to_v4_renames_status_done() { + let node = create_task_node(3, "DONE"); + let migrated = migrate_v3_to_v4(&node).unwrap(); + + assert_eq!(migrated.properties["_schema_version"], 4); + assert_eq!(migrated.properties["status"], "COMPLETED"); + } + + #[test] + fn test_migrate_v3_to_v4_preserves_unknown_status() { + let node = create_task_node(3, "CUSTOM_STATUS"); + let migrated = migrate_v3_to_v4(&node).unwrap(); + + assert_eq!(migrated.properties["_schema_version"], 4); + assert_eq!(migrated.properties["status"], "CUSTOM_STATUS"); + } + + #[test] + fn test_migration_chain_v1_to_v4() { + let mut registry = MigrationRegistry::new(); + register_migrations(&mut registry); + + let node = create_task_node(1, "OPEN"); + let migrated = registry.apply_migrations(&node, 4).unwrap(); + + assert_eq!(migrated.properties["_schema_version"], 4); + assert_eq!(migrated.properties["status"], "TODO"); // OPEN → TODO + assert_eq!(migrated.properties["priority"], 0); // Added in v1→v2 + assert_eq!(migrated.properties["assignee"], json!(null)); // Added in v2→v3 + } + + #[test] + fn test_register_migrations_adds_all() { + let mut registry = MigrationRegistry::new(); + assert_eq!(registry.migration_count(), 0); + + register_migrations(&mut registry); + + assert_eq!(registry.migration_count(), 3); + assert!(registry.has_migration_path("task", 1, 4)); + } + + #[test] + fn test_migration_preserves_custom_fields() { + let mut node = create_task_node(1, "OPEN"); + if let Some(obj) = node.properties.as_object_mut() { + obj.insert("custom_field".to_string(), json!("custom_value")); + obj.insert("tags".to_string(), json!(["important", "urgent"])); + } + + let migrated = migrate_v1_to_v2(&node).unwrap(); + + // New field added + assert_eq!(migrated.properties["priority"], 0); + + // Custom fields preserved + assert_eq!(migrated.properties["custom_field"], "custom_value"); + assert_eq!(migrated.properties["tags"][0], "important"); + assert_eq!(migrated.properties["tags"][1], "urgent"); + } +} diff --git a/packages/core/src/services/mod.rs b/packages/core/src/services/mod.rs index c21a7a59b..e5b82cbb2 100644 --- a/packages/core/src/services/mod.rs +++ b/packages/core/src/services/mod.rs @@ -5,7 +5,8 @@ //! - `NodeService` - CRUD operations and hierarchy management //! - `NodeEmbeddingService` - Embedding generation and semantic search //! - `EmbeddingProcessor` - Background task for processing stale container embeddings -//! - `SchemaService` - Dynamic schema creation and validation (planned) +//! - `SchemaService` - Schema management with version tracking and migrations +//! - `MigrationRegistry` - Schema migration infrastructure for lazy upgrades //! - `SearchService` - Semantic search and query operations (planned) //! //! Services coordinate between the database layer and application logic, @@ -14,7 +15,10 @@ pub mod embedding_processor; pub mod embedding_service; pub mod error; +pub mod migration_registry; +pub mod migrations; pub mod node_service; +pub mod schema_service; #[cfg(test)] mod node_service_container_test; @@ -22,4 +26,6 @@ mod node_service_container_test; pub use embedding_processor::{EmbeddingProcessor, EmbeddingProcessorConfig}; pub use embedding_service::{NodeEmbeddingService, EMBEDDING_DIMENSION}; pub use error::NodeServiceError; +pub use migration_registry::{MigrationRegistry, MigrationTransform}; pub use node_service::NodeService; +pub use schema_service::SchemaService; diff --git a/packages/core/src/services/node_service.rs b/packages/core/src/services/node_service.rs index 0d8572add..8943c15f6 100644 --- a/packages/core/src/services/node_service.rs +++ b/packages/core/src/services/node_service.rs @@ -28,6 +28,8 @@ use crate::behaviors::NodeBehaviorRegistry; use crate::db::DatabaseService; use crate::models::{Node, NodeFilter, NodeUpdate, OrderBy}; use crate::services::error::NodeServiceError; +use crate::services::migration_registry::MigrationRegistry; +use crate::services::migrations; use chrono::{DateTime, NaiveDateTime, Utc}; use regex::Regex; use std::collections::HashSet; @@ -269,6 +271,9 @@ pub struct NodeService { /// Behavior registry for validation behaviors: Arc, + + /// Migration registry for lazy schema upgrades + migration_registry: Arc, } impl NodeService { @@ -295,9 +300,14 @@ impl NodeService { /// # } /// ``` pub fn new(db: DatabaseService) -> Result { + // Create and initialize migration registry with all registered migrations + let mut migration_registry = MigrationRegistry::new(); + migrations::task::register_migrations(&mut migration_registry); + Ok(Self { db: Arc::new(db), behaviors: Arc::new(NodeBehaviorRegistry::new()), + migration_registry: Arc::new(migration_registry), }) } @@ -344,6 +354,166 @@ impl NodeService { /// # Ok(()) /// # } /// ``` + /// Get schema definition for a given node type + /// + /// Queries the schema node directly from the database. + /// Schema nodes are stored with id = node_type and node_type = "schema". + /// + /// # Arguments + /// + /// * `node_type` - The type of node to get the schema for (e.g., "task", "person") + /// + /// # Returns + /// + /// * `Ok(Some(value))` - Schema definition as JSON value if found + /// * `Ok(None)` - No schema found for this node type + /// * `Err` - Database error + async fn get_schema_for_type( + &self, + node_type: &str, + ) -> Result, NodeServiceError> { + let conn = self.db.connect_with_timeout().await?; + + let mut stmt = conn + .prepare("SELECT properties FROM nodes WHERE id = ? AND node_type = 'schema'") + .await + .map_err(|e| { + NodeServiceError::query_failed(format!("Failed to prepare schema query: {}", e)) + })?; + + let mut rows = stmt + .query([node_type]) + .await + .map_err(|e| NodeServiceError::query_failed(format!("Failed to get schema: {}", e)))?; + + if let Some(row) = rows + .next() + .await + .map_err(|e| NodeServiceError::query_failed(e.to_string()))? + { + let properties_str: String = row.get(0).map_err(|e| { + NodeServiceError::query_failed(format!("Failed to get properties column: {}", e)) + })?; + + let schema: serde_json::Value = serde_json::from_str(&properties_str) + .map_err(|e| NodeServiceError::serialization_error(e.to_string()))?; + + Ok(Some(schema)) + } else { + Ok(None) + } + } + + /// Backfill _schema_version for a node if it doesn't have one (Phase 1 lazy migration) + /// + /// # Arguments + /// + /// * `node` - Mutable reference to the node to backfill + /// + /// # Returns + /// + /// * `Ok(())` - Version was already present or successfully backfilled + /// * `Err` - Database error during backfill + async fn backfill_schema_version(&self, node: &mut Node) -> Result<(), NodeServiceError> { + if let Some(props_obj) = node.properties.as_object() { + if !props_obj.contains_key("_schema_version") { + // Node doesn't have schema version - backfill it + let version = + if let Some(schema) = self.get_schema_for_type(&node.node_type).await? { + schema.get("version").and_then(|v| v.as_i64()).unwrap_or(1) + } else { + 1 // Default to version 1 if no schema found + }; + + // Add version to node properties + let mut updated_props = node.properties.clone(); + if let Some(props_obj) = updated_props.as_object_mut() { + props_obj.insert("_schema_version".to_string(), serde_json::json!(version)); + } + + // Persist the backfilled version to database + let conn = self.db.connect_with_timeout().await?; + let properties_json = serde_json::to_string(&updated_props) + .map_err(|e| NodeServiceError::serialization_error(e.to_string()))?; + + conn.execute( + "UPDATE nodes SET properties = ?, modified_at = CURRENT_TIMESTAMP WHERE id = ?", + (properties_json.as_str(), node.id.as_str()), + ) + .await + .map_err(|e| { + NodeServiceError::query_failed(format!( + "Failed to backfill schema version: {}", + e + )) + })?; + + // Update the in-memory node with new properties + node.properties = updated_props; + } + } + Ok(()) + } + + /// Apply lazy migration to upgrade node to latest schema version + /// + /// Checks if the node's schema version is older than the current schema version, + /// and if so, applies migration transforms to upgrade it. + /// + /// # Arguments + /// + /// * `node` - Mutable reference to the node to migrate + /// + /// # Returns + /// + /// * `Ok(())` - Node was already up-to-date or successfully migrated + /// * `Err` - Migration failed or database error + async fn apply_lazy_migration(&self, node: &mut Node) -> Result<(), NodeServiceError> { + // Get current version from node + let current_version = node + .properties + .get("_schema_version") + .and_then(|v| v.as_u64()) + .unwrap_or(1) as u32; + + // Get target version from schema + let target_version = if let Some(schema) = self.get_schema_for_type(&node.node_type).await? + { + schema.get("version").and_then(|v| v.as_i64()).unwrap_or(1) as u32 + } else { + 1 // No schema found - no migration needed + }; + + // Check if migration is needed + if current_version >= target_version { + return Ok(()); // Already up-to-date + } + + // Apply migrations + let migrated_node = self + .migration_registry + .apply_migrations(node, target_version)?; + + // Persist migrated node to database + let conn = self.db.connect_with_timeout().await?; + let properties_json = serde_json::to_string(&migrated_node.properties) + .map_err(|e| NodeServiceError::serialization_error(e.to_string()))?; + + conn.execute( + "UPDATE nodes SET properties = ?, modified_at = CURRENT_TIMESTAMP WHERE id = ?", + (properties_json.as_str(), node.id.as_str()), + ) + .await + .map_err(|e| { + NodeServiceError::query_failed(format!("Failed to persist migrated node: {}", e)) + })?; + + // Update the in-memory node + *node = migrated_node; + + Ok(()) + } + pub async fn create_node(&self, node: Node) -> Result { // Validate node using behavior registry self.behaviors.validate_node(&node)?; @@ -355,7 +525,20 @@ impl NodeService { if !parent_exists { // Check if this is a date node (format: YYYY-MM-DD) if is_date_node_id(parent_id) { - // Auto-create the date node + // Auto-create the date node with schema version + let mut date_properties = serde_json::Map::new(); + + // Add schema version for date node + if let Some(schema) = self.get_schema_for_type("date").await? { + if let Some(version) = schema.get("version").and_then(|v| v.as_i64()) { + date_properties + .insert("_schema_version".to_string(), serde_json::json!(version)); + } + } else { + // No schema found - use version 1 as default + date_properties.insert("_schema_version".to_string(), serde_json::json!(1)); + } + let date_node = Node { id: parent_id.clone(), node_type: "date".to_string(), @@ -364,7 +547,7 @@ impl NodeService { container_node_id: None, before_sibling_id: None, version: 1, - properties: serde_json::Value::Object(serde_json::Map::new()), + properties: serde_json::Value::Object(date_properties), mentions: vec![], mentioned_by: vec![], created_at: chrono::Utc::now(), @@ -374,7 +557,8 @@ impl NodeService { // Insert date node directly (skip validation to avoid recursion) let conn = self.db.connect_with_timeout().await?; - let properties_json = "{}"; + let properties_json = serde_json::to_string(&date_node.properties) + .map_err(|e| NodeServiceError::serialization_error(e.to_string()))?; conn.execute( "INSERT INTO nodes (id, node_type, content, parent_id, container_node_id, before_sibling_id, version, properties, embedding_vector) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", @@ -413,7 +597,24 @@ impl NodeService { // Database defaults handle created_at and modified_at timestamps automatically let conn = self.db.connect_with_timeout().await?; - let properties_json = serde_json::to_string(&node.properties) + // Add schema version to properties + // Get schema for this node type and extract version + let mut properties = node.properties.clone(); + if let Some(schema) = self.get_schema_for_type(&node.node_type).await? { + if let Some(version) = schema.get("version").and_then(|v| v.as_i64()) { + // Add _schema_version to properties + if let Some(props_obj) = properties.as_object_mut() { + props_obj.insert("_schema_version".to_string(), serde_json::json!(version)); + } + } + } else { + // No schema found - use version 1 as default + if let Some(props_obj) = properties.as_object_mut() { + props_obj.insert("_schema_version".to_string(), serde_json::json!(1)); + } + } + + let properties_json = serde_json::to_string(&properties) .map_err(|e| NodeServiceError::serialization_error(e.to_string()))?; // Convert ROOT_CONTAINER_ID to None (null in database) @@ -648,6 +849,8 @@ impl NodeService { { let mut node = self.row_to_node(row)?; self.populate_mentions(&mut node).await?; + self.backfill_schema_version(&mut node).await?; + self.apply_lazy_migration(&mut node).await?; Ok(Some(node)) } else { Ok(None) @@ -1578,7 +1781,10 @@ impl NodeService { .await .map_err(|e| NodeServiceError::query_failed(e.to_string()))? { - nodes.push(self.row_to_node(row)?); + let mut node = self.row_to_node(row)?; + self.backfill_schema_version(&mut node).await?; + self.apply_lazy_migration(&mut node).await?; + nodes.push(node); } return Ok(nodes); @@ -1616,7 +1822,10 @@ impl NodeService { .await .map_err(|e| NodeServiceError::query_failed(e.to_string()))? { - nodes.push(self.row_to_node(row)?); + let mut node = self.row_to_node(row)?; + self.backfill_schema_version(&mut node).await?; + self.apply_lazy_migration(&mut node).await?; + nodes.push(node); } return Ok(nodes); @@ -1657,7 +1866,10 @@ impl NodeService { .await .map_err(|e| NodeServiceError::query_failed(e.to_string()))? { - nodes.push(self.row_to_node(row)?); + let mut node = self.row_to_node(row)?; + self.backfill_schema_version(&mut node).await?; + self.apply_lazy_migration(&mut node).await?; + nodes.push(node); } return Ok(nodes); @@ -1967,7 +2179,10 @@ impl NodeService { .await .map_err(|e| NodeServiceError::query_failed(e.to_string()))? { - nodes.push(self.row_to_node(row)?); + let mut node = self.row_to_node(row)?; + self.backfill_schema_version(&mut node).await?; + self.apply_lazy_migration(&mut node).await?; + nodes.push(node); } return Ok(nodes); @@ -2005,7 +2220,10 @@ impl NodeService { .await .map_err(|e| NodeServiceError::query_failed(e.to_string()))? { - nodes.push(self.row_to_node(row)?); + let mut node = self.row_to_node(row)?; + self.backfill_schema_version(&mut node).await?; + self.apply_lazy_migration(&mut node).await?; + nodes.push(node); } return Ok(nodes); @@ -4577,5 +4795,159 @@ mod tests { // Delete again (should still succeed - idempotent) service.delete_mention(&node1_id, &node2_id).await.unwrap(); } + + // Phase 1: Version Tracking Tests + mod version_tracking_tests { + use super::*; + + #[tokio::test] + async fn test_new_nodes_get_schema_version() { + let (service, _temp) = create_test_service().await; + + // Create a text node (no schema exists, should default to version 1) + let node = Node::new( + "text".to_string(), + "Test content".to_string(), + None, + json!({}), + ); + + let id = service.create_node(node).await.unwrap(); + let retrieved = service.get_node(&id).await.unwrap().unwrap(); + + // Verify _schema_version was added + assert!(retrieved.properties.get("_schema_version").is_some()); + assert_eq!(retrieved.properties["_schema_version"], 1); + } + + #[tokio::test] + async fn test_backfill_existing_nodes_without_version() { + let (service, _temp) = create_test_service().await; + + // Manually insert a node without _schema_version (simulating existing data) + let conn = service.db.connect_with_timeout().await.unwrap(); + let node_id = "test-node-no-version"; + let properties_json = json!({"some_field": "value"}).to_string(); + + conn.execute( + "INSERT INTO nodes (id, node_type, content, parent_id, container_node_id, before_sibling_id, properties, embedding_vector) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + node_id, + "text", + "Test content", + None::<&str>, + None::<&str>, + None::<&str>, + properties_json.as_str(), + None::<&str>, + ), + ) + .await + .unwrap(); + + // Retrieve the node - should trigger backfill + let retrieved = service.get_node(node_id).await.unwrap().unwrap(); + + // Verify _schema_version was backfilled + assert!(retrieved.properties.get("_schema_version").is_some()); + assert_eq!(retrieved.properties["_schema_version"], 1); + + // Verify original field is still present + assert_eq!(retrieved.properties["some_field"], "value"); + + // Verify the backfill was persisted to database + let retrieved_again = service.get_node(node_id).await.unwrap().unwrap(); + assert_eq!(retrieved_again.properties["_schema_version"], 1); + } + + #[tokio::test] + async fn test_query_nodes_backfills_versions() { + let (service, _temp) = create_test_service().await; + + // Manually insert multiple nodes without _schema_version + let conn = service.db.connect_with_timeout().await.unwrap(); + + for i in 1..=3 { + let node_id = format!("test-node-{}", i); + let properties_json = json!({"field": i}).to_string(); + + conn.execute( + "INSERT INTO nodes (id, node_type, content, parent_id, container_node_id, before_sibling_id, properties, embedding_vector) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + node_id.as_str(), + "text", + format!("Content {}", i).as_str(), + None::<&str>, + None::<&str>, + None::<&str>, + properties_json.as_str(), + None::<&str>, + ), + ) + .await + .unwrap(); + } + + // Query all text nodes - should trigger backfill for all + let filter = NodeFilter::new().with_node_type("text".to_string()); + let nodes = service.query_nodes(filter).await.unwrap(); + + // Verify all nodes got _schema_version backfilled + assert!(nodes.len() >= 3); + for node in &nodes { + assert!(node.properties.get("_schema_version").is_some()); + assert_eq!(node.properties["_schema_version"], 1); + } + } + + #[tokio::test] + async fn test_auto_created_date_nodes_get_version() { + let (service, _temp) = create_test_service().await; + + // Create a node with date parent (will auto-create date node) + let text_node = Node::new_with_id( + "test-text-node".to_string(), + "text".to_string(), + "Test content".to_string(), + Some("2025-01-15".to_string()), + json!({}), + ); + + service.create_node(text_node).await.unwrap(); + + // Verify the auto-created date node has _schema_version + let date_node = service.get_node("2025-01-15").await.unwrap().unwrap(); + assert!(date_node.properties.get("_schema_version").is_some()); + assert_eq!(date_node.properties["_schema_version"], 1); + } + + #[tokio::test] + async fn test_nodes_with_existing_version_not_modified() { + let (service, _temp) = create_test_service().await; + + // Create a node (will get version 1) + let node = Node::new( + "text".to_string(), + "Test content".to_string(), + None, + json!({}), + ); + + let id = service.create_node(node).await.unwrap(); + + // Retrieve twice to ensure version doesn't get modified on subsequent access + let retrieved1 = service.get_node(&id).await.unwrap().unwrap(); + let retrieved2 = service.get_node(&id).await.unwrap().unwrap(); + + assert_eq!(retrieved1.properties["_schema_version"], 1); + assert_eq!(retrieved2.properties["_schema_version"], 1); + assert_eq!( + retrieved1.modified_at, retrieved2.modified_at, + "Modified timestamp should not change on backfill skip" + ); + } + } } } diff --git a/packages/core/src/services/schema_service.rs b/packages/core/src/services/schema_service.rs new file mode 100644 index 000000000..aa9895985 --- /dev/null +++ b/packages/core/src/services/schema_service.rs @@ -0,0 +1,713 @@ +//! Schema Management Service +//! +//! This service provides operations for managing user-defined entity schemas +//! using the Pure JSON schema-as-node pattern. Schemas are stored as regular +//! nodes with `node_type = 'schema'` and follow the convention `id = type_name`. +//! +//! ## Core Functionality +//! +//! - Get schema definitions by schema ID +//! - Add/remove user fields (protection enforced) +//! - Extend/remove enum values (user_values only) +//! - Automatic version incrementing on schema changes +//! +//! ## Protection Enforcement +//! +//! - **Core fields**: Cannot be modified or deleted (UI depends on these) +//! - **User fields**: Fully modifiable/deletable by users +//! - **System fields**: Auto-managed, read-only (future use) +//! +//! ## Example Usage +//! +//! ```no_run +//! # use nodespace_core::services::{NodeService, SchemaService}; +//! # use nodespace_core::models::schema::{SchemaField, ProtectionLevel}; +//! # use std::sync::Arc; +//! # #[tokio::main] +//! # async fn main() -> Result<(), Box> { +//! let node_service = Arc::new(NodeService::new(db)?); +//! let schema_service = SchemaService::new(node_service); +//! +//! // Get schema definition +//! let schema = schema_service.get_schema("task").await?; +//! println!("Task schema version: {}", schema.version); +//! +//! // Add a new user field +//! let priority_field = SchemaField { +//! name: "priority".to_string(), +//! field_type: "number".to_string(), +//! protection: ProtectionLevel::User, +//! indexed: false, +//! required: Some(false), +//! // ... other fields +//! }; +//! schema_service.add_field("task", priority_field).await?; +//! +//! // Extend an enum field +//! schema_service.extend_enum_field("task", "status", "BLOCKED".to_string()).await?; +//! # Ok(()) +//! # } +//! ``` + +use crate::models::schema::{ProtectionLevel, SchemaDefinition, SchemaField}; +use crate::models::NodeUpdate; +use crate::services::node_service::NodeService; +use crate::services::NodeServiceError; +use std::sync::Arc; + +/// Service for managing user-defined entity schemas +/// +/// Wraps NodeService to provide specialized operations for schema nodes, +/// enforcing protection levels and maintaining version integrity. +pub struct SchemaService { + node_service: Arc, +} + +impl SchemaService { + /// Create a new SchemaService + /// + /// # Arguments + /// + /// * `node_service` - NodeService instance for node operations + pub fn new(node_service: Arc) -> Self { + Self { node_service } + } + + /// Get schema definition by schema ID + /// + /// Schemas follow the convention: `id = type_name`, `node_type = "schema"`. + /// + /// # Arguments + /// + /// * `schema_id` - The schema ID (e.g., "task", "person") + /// + /// # Returns + /// + /// The parsed SchemaDefinition from the node's properties + /// + /// # Errors + /// + /// - `NodeNotFound`: Schema node doesn't exist + /// - `InvalidNodeType`: Node exists but is not a schema node + /// - `SerializationError`: Properties cannot be parsed as SchemaDefinition + /// + /// # Example + /// + /// ```no_run + /// # use nodespace_core::services::SchemaService; + /// # async fn example(service: SchemaService) -> Result<(), Box> { + /// let schema = service.get_schema("task").await?; + /// println!("Fields: {}", schema.fields.len()); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_schema(&self, schema_id: &str) -> Result { + let node = self + .node_service + .get_node(schema_id) + .await? + .ok_or_else(|| NodeServiceError::node_not_found(schema_id))?; + + if node.node_type != "schema" { + return Err(NodeServiceError::invalid_update(format!( + "Node '{}' is not a schema (type: {})", + schema_id, node.node_type + ))); + } + + let schema: SchemaDefinition = serde_json::from_value(node.properties) + .map_err(|e| NodeServiceError::serialization_error(e.to_string()))?; + + Ok(schema) + } + + /// Add a new field to a schema + /// + /// Only user-protected fields can be added. Attempting to add core or system + /// fields will return an error. + /// + /// The schema version is automatically incremented. + /// + /// # Arguments + /// + /// * `schema_id` - The schema ID to modify + /// * `field` - The field definition to add + /// + /// # Errors + /// + /// - `InvalidUpdate`: Field has non-user protection level + /// - `InvalidUpdate`: Field name already exists in schema + /// - Any errors from `get_schema` or `update_schema` + /// + /// # Example + /// + /// ```no_run + /// # use nodespace_core::services::SchemaService; + /// # use nodespace_core::models::schema::{SchemaField, ProtectionLevel}; + /// # async fn example(service: SchemaService) -> Result<(), Box> { + /// let field = SchemaField { + /// name: "priority".to_string(), + /// field_type: "number".to_string(), + /// protection: ProtectionLevel::User, + /// indexed: false, + /// required: Some(false), + /// extensible: None, + /// default: Some(serde_json::json!(0)), + /// core_values: None, + /// user_values: None, + /// description: Some("Task priority".to_string()), + /// item_type: None, + /// }; + /// service.add_field("task", field).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn add_field( + &self, + schema_id: &str, + field: SchemaField, + ) -> Result<(), NodeServiceError> { + // Protection: Only user fields can be added + if field.protection != ProtectionLevel::User { + return Err(NodeServiceError::invalid_update(format!( + "Can only add user-protected fields. Field '{}' has protection: {:?}", + field.name, field.protection + ))); + } + + let mut schema = self.get_schema(schema_id).await?; + + // Check if field already exists + if schema.fields.iter().any(|f| f.name == field.name) { + return Err(NodeServiceError::invalid_update(format!( + "Field '{}' already exists in schema '{}'", + field.name, schema_id + ))); + } + + // Add field and bump version + schema.fields.push(field); + schema.version += 1; + + // Update schema node + self.update_schema(schema_id, schema).await?; + + Ok(()) + } + + /// Remove a field from a schema + /// + /// Only user-protected fields can be removed. Attempting to remove core or + /// system fields will return an error. + /// + /// The schema version is automatically incremented. + /// + /// # Arguments + /// + /// * `schema_id` - The schema ID to modify + /// * `field_name` - The name of the field to remove + /// + /// # Errors + /// + /// - `InvalidUpdate`: Field not found in schema + /// - `InvalidUpdate`: Field has non-user protection level + /// - Any errors from `get_schema` or `update_schema` + /// + /// # Example + /// + /// ```no_run + /// # use nodespace_core::services::SchemaService; + /// # async fn example(service: SchemaService) -> Result<(), Box> { + /// service.remove_field("task", "priority").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn remove_field( + &self, + schema_id: &str, + field_name: &str, + ) -> Result<(), NodeServiceError> { + let mut schema = self.get_schema(schema_id).await?; + + // Find the field + let field_idx = schema + .fields + .iter() + .position(|f| f.name == field_name) + .ok_or_else(|| { + NodeServiceError::invalid_update(format!( + "Field '{}' not found in schema '{}'", + field_name, schema_id + )) + })?; + + let field = &schema.fields[field_idx]; + + // Protection: Only user fields can be removed + if field.protection != ProtectionLevel::User { + return Err(NodeServiceError::invalid_update(format!( + "Cannot remove field '{}' with protection level {:?}. Only user fields can be removed.", + field_name, field.protection + ))); + } + + // Remove field and bump version + schema.fields.remove(field_idx); + schema.version += 1; + + // Update schema node + self.update_schema(schema_id, schema).await?; + + Ok(()) + } + + /// Extend an enum field with a new value + /// + /// Adds a value to the `user_values` array of an enum field. The field must + /// be marked as `extensible = true`. + /// + /// Core values cannot be added through this method (they are defined at + /// schema creation time). + /// + /// The schema version is automatically incremented. + /// + /// # Arguments + /// + /// * `schema_id` - The schema ID to modify + /// * `field_name` - The name of the enum field + /// * `new_value` - The value to add to user_values + /// + /// # Errors + /// + /// - `InvalidUpdate`: Field not found + /// - `InvalidUpdate`: Field is not an enum + /// - `InvalidUpdate`: Field is not extensible + /// - `InvalidUpdate`: Value already exists (in core or user values) + /// - Any errors from `get_schema` or `update_schema` + /// + /// # Example + /// + /// ```no_run + /// # use nodespace_core::services::SchemaService; + /// # async fn example(service: SchemaService) -> Result<(), Box> { + /// // Add "BLOCKED" to task.status enum + /// service.extend_enum_field("task", "status", "BLOCKED".to_string()).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn extend_enum_field( + &self, + schema_id: &str, + field_name: &str, + new_value: String, + ) -> Result<(), NodeServiceError> { + let mut schema = self.get_schema(schema_id).await?; + + // Check if value already exists (in core or user values) - do this BEFORE getting mutable ref + let all_values = schema.get_enum_values(field_name).unwrap_or_default(); + if all_values.contains(&new_value) { + return Err(NodeServiceError::invalid_update(format!( + "Value '{}' already exists in enum '{}'", + new_value, field_name + ))); + } + + // Find the field + let field = schema + .fields + .iter_mut() + .find(|f| f.name == field_name) + .ok_or_else(|| { + NodeServiceError::invalid_update(format!( + "Field '{}' not found in schema '{}'", + field_name, schema_id + )) + })?; + + // Verify it's an enum field + if field.field_type != "enum" { + return Err(NodeServiceError::invalid_update(format!( + "Field '{}' is not an enum (type: {})", + field_name, field.field_type + ))); + } + + // Verify it's extensible + if !field.extensible.unwrap_or(false) { + return Err(NodeServiceError::invalid_update(format!( + "Enum field '{}' is not extensible", + field_name + ))); + } + + // Add to user_values + field + .user_values + .get_or_insert_with(Vec::new) + .push(new_value); + schema.version += 1; + + // Update schema node + self.update_schema(schema_id, schema).await?; + + Ok(()) + } + + /// Remove a value from an enum field + /// + /// Removes a value from the `user_values` array of an enum field. Core values + /// cannot be removed - they are protected. + /// + /// The schema version is automatically incremented. + /// + /// # Arguments + /// + /// * `schema_id` - The schema ID to modify + /// * `field_name` - The name of the enum field + /// * `value` - The value to remove from user_values + /// + /// # Errors + /// + /// - `InvalidUpdate`: Field not found + /// - `InvalidUpdate`: Value is a core value (cannot remove) + /// - `InvalidUpdate`: Value not found in user_values + /// - Any errors from `get_schema` or `update_schema` + /// + /// # Example + /// + /// ```no_run + /// # use nodespace_core::services::SchemaService; + /// # async fn example(service: SchemaService) -> Result<(), Box> { + /// // Remove "BLOCKED" from task.status enum (user value only) + /// service.remove_enum_value("task", "status", "BLOCKED").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn remove_enum_value( + &self, + schema_id: &str, + field_name: &str, + value: &str, + ) -> Result<(), NodeServiceError> { + let mut schema = self.get_schema(schema_id).await?; + + // Find the field + let field = schema + .fields + .iter_mut() + .find(|f| f.name == field_name) + .ok_or_else(|| { + NodeServiceError::invalid_update(format!( + "Field '{}' not found in schema '{}'", + field_name, schema_id + )) + })?; + + // Check if value is in core_values (cannot remove) + if let Some(core_vals) = &field.core_values { + if core_vals.contains(&value.to_string()) { + return Err(NodeServiceError::invalid_update(format!( + "Cannot remove core value '{}' from enum '{}'. Only user values can be removed.", + value, field_name + ))); + } + } + + // Remove from user_values + if let Some(user_vals) = &mut field.user_values { + if let Some(idx) = user_vals.iter().position(|v| v == value) { + user_vals.remove(idx); + schema.version += 1; + + // Update schema node + self.update_schema(schema_id, schema).await?; + return Ok(()); + } + } + + Err(NodeServiceError::invalid_update(format!( + "Value '{}' not found in user values of enum '{}'", + value, field_name + ))) + } + + /// Update schema node with new definition + /// + /// Internal helper method to persist schema changes. + /// + /// # Arguments + /// + /// * `schema_id` - The schema ID to update + /// * `schema` - The new schema definition + /// + /// # Errors + /// + /// - `SerializationError`: Schema cannot be serialized to JSON + /// - Any errors from `NodeService::update_node` + async fn update_schema( + &self, + schema_id: &str, + schema: SchemaDefinition, + ) -> Result<(), NodeServiceError> { + let properties = serde_json::to_value(&schema) + .map_err(|e| NodeServiceError::serialization_error(e.to_string()))?; + + self.node_service + .update_node( + schema_id, + NodeUpdate { + properties: Some(properties), + ..Default::default() + }, + ) + .await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::DatabaseService; + use crate::models::Node; + use serde_json::json; + use tempfile::TempDir; + + async fn setup_test_service() -> (SchemaService, TempDir) { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let db = DatabaseService::new(db_path).await.unwrap(); + let node_service = Arc::new(NodeService::new(db).unwrap()); + let schema_service = SchemaService::new(node_service); + + (schema_service, temp_dir) + } + + async fn create_test_schema(service: &SchemaService) -> String { + let schema = SchemaDefinition { + is_core: false, + version: 1, + description: "Test widget schema".to_string(), + fields: vec![ + SchemaField { + name: "status".to_string(), + field_type: "enum".to_string(), + protection: ProtectionLevel::Core, + core_values: Some(vec!["OPEN".to_string(), "DONE".to_string()]), + user_values: Some(vec![]), + indexed: true, + required: Some(true), + extensible: Some(true), + default: Some(json!("OPEN")), + description: Some("Widget status".to_string()), + item_type: None, + }, + SchemaField { + name: "priority".to_string(), + field_type: "number".to_string(), + protection: ProtectionLevel::User, + core_values: None, + user_values: None, + indexed: false, + required: Some(false), + extensible: None, + default: Some(json!(0)), + description: Some("Widget priority".to_string()), + item_type: None, + }, + ], + }; + + let schema_node = Node { + id: "test_widget".to_string(), + node_type: "schema".to_string(), + content: "Test Widget".to_string(), + parent_id: None, + container_node_id: None, + before_sibling_id: None, + version: 1, + created_at: chrono::Utc::now(), + modified_at: chrono::Utc::now(), + properties: serde_json::to_value(&schema).unwrap(), + embedding_vector: None, + mentions: Vec::new(), + mentioned_by: Vec::new(), + }; + + service.node_service.create_node(schema_node).await.unwrap(); + + "test_widget".to_string() + } + + #[tokio::test] + async fn test_get_schema() { + let (service, _temp) = setup_test_service().await; + let schema_id = create_test_schema(&service).await; + + let schema = service.get_schema(&schema_id).await.unwrap(); + + assert_eq!(schema.version, 1); + assert_eq!(schema.fields.len(), 2); + assert_eq!(schema.fields[0].name, "status"); + assert_eq!(schema.fields[1].name, "priority"); + } + + #[tokio::test] + async fn test_get_schema_not_found() { + let (service, _temp) = setup_test_service().await; + + let result = service.get_schema("nonexistent").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_add_user_field() { + let (service, _temp) = setup_test_service().await; + let schema_id = create_test_schema(&service).await; + + let new_field = SchemaField { + name: "due_date".to_string(), + field_type: "string".to_string(), + protection: ProtectionLevel::User, + core_values: None, + user_values: None, + indexed: false, + required: Some(false), + extensible: None, + default: None, + description: Some("Due date".to_string()), + item_type: None, + }; + + service.add_field(&schema_id, new_field).await.unwrap(); + + let schema = service.get_schema(&schema_id).await.unwrap(); + assert_eq!(schema.version, 2); // Version incremented + assert_eq!(schema.fields.len(), 3); + assert_eq!(schema.fields[2].name, "due_date"); + } + + #[tokio::test] + async fn test_add_core_field_rejected() { + let (service, _temp) = setup_test_service().await; + let schema_id = create_test_schema(&service).await; + + let core_field = SchemaField { + name: "bad_field".to_string(), + field_type: "string".to_string(), + protection: ProtectionLevel::Core, + core_values: None, + user_values: None, + indexed: false, + required: Some(false), + extensible: None, + default: None, + description: None, + item_type: None, + }; + + let result = service.add_field(&schema_id, core_field).await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Can only add user-protected fields")); + } + + #[tokio::test] + async fn test_remove_user_field() { + let (service, _temp) = setup_test_service().await; + let schema_id = create_test_schema(&service).await; + + service.remove_field(&schema_id, "priority").await.unwrap(); + + let schema = service.get_schema(&schema_id).await.unwrap(); + assert_eq!(schema.version, 2); // Version incremented + assert_eq!(schema.fields.len(), 1); + assert_eq!(schema.fields[0].name, "status"); + } + + #[tokio::test] + async fn test_remove_core_field_rejected() { + let (service, _temp) = setup_test_service().await; + let schema_id = create_test_schema(&service).await; + + let result = service.remove_field(&schema_id, "status").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Only user fields can be removed")); + } + + #[tokio::test] + async fn test_extend_enum_field() { + let (service, _temp) = setup_test_service().await; + let schema_id = create_test_schema(&service).await; + + service + .extend_enum_field(&schema_id, "status", "BLOCKED".to_string()) + .await + .unwrap(); + + let schema = service.get_schema(&schema_id).await.unwrap(); + assert_eq!(schema.version, 2); // Version incremented + + let values = schema.get_enum_values("status").unwrap(); + assert_eq!(values.len(), 3); + assert!(values.contains(&"BLOCKED".to_string())); + } + + #[tokio::test] + async fn test_extend_non_enum_field_rejected() { + let (service, _temp) = setup_test_service().await; + let schema_id = create_test_schema(&service).await; + + let result = service + .extend_enum_field(&schema_id, "priority", "HIGH".to_string()) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not an enum")); + } + + #[tokio::test] + async fn test_remove_user_enum_value() { + let (service, _temp) = setup_test_service().await; + let schema_id = create_test_schema(&service).await; + + // First add a user value + service + .extend_enum_field(&schema_id, "status", "BLOCKED".to_string()) + .await + .unwrap(); + + // Then remove it + service + .remove_enum_value(&schema_id, "status", "BLOCKED") + .await + .unwrap(); + + let schema = service.get_schema(&schema_id).await.unwrap(); + assert_eq!(schema.version, 3); // Two version increments + + let values = schema.get_enum_values("status").unwrap(); + assert_eq!(values.len(), 2); // Back to core values only + assert!(!values.contains(&"BLOCKED".to_string())); + } + + #[tokio::test] + async fn test_remove_core_enum_value_rejected() { + let (service, _temp) = setup_test_service().await; + let schema_id = create_test_schema(&service).await; + + let result = service + .remove_enum_value(&schema_id, "status", "OPEN") + .await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Cannot remove core value")); + } +} diff --git a/packages/desktop-app/src-tauri/src/mcp_integration.rs b/packages/desktop-app/src-tauri/src/mcp_integration.rs index de181ac32..73cfd5488 100644 --- a/packages/desktop-app/src-tauri/src/mcp_integration.rs +++ b/packages/desktop-app/src-tauri/src/mcp_integration.rs @@ -6,7 +6,7 @@ use nodespace_core::mcp; use nodespace_core::operations::NodeOperations; -use nodespace_core::services::NodeEmbeddingService; +use nodespace_core::services::{NodeEmbeddingService, SchemaService}; use nodespace_core::{Node, NodeService}; use serde::Serialize; use serde_json::Value; @@ -61,12 +61,16 @@ pub async fn run_mcp_server_with_events( }); // Create NodeOperations from NodeService - let node_operations = Arc::new(NodeOperations::new(node_service)); + let node_operations = Arc::new(NodeOperations::new(node_service.clone())); + + // Create SchemaService from NodeService + let schema_service = Arc::new(SchemaService::new(node_service)); // Create combined services struct for MCP let services = mcp::server::McpServices { node_operations, embedding_service, + schema_service, }; // Run core MCP server with HTTP transport and event-emitting callback diff --git a/packages/desktop-app/src/lib/services/schema-service.ts b/packages/desktop-app/src/lib/services/schema-service.ts new file mode 100644 index 000000000..8c1e85284 --- /dev/null +++ b/packages/desktop-app/src/lib/services/schema-service.ts @@ -0,0 +1,517 @@ +/** + * Schema Service - TypeScript Wrapper for Schema Management + * + * Provides a type-safe TypeScript wrapper around the Rust backend's schema + * management MCP tools. Enables frontend components to manage user-defined + * entity schemas with full type safety. + * + * ## Features + * + * - Add/remove user fields from schemas + * - Extend/remove enum values (user_values only) + * - Get complete schema definitions + * - Protection level enforcement (core fields protected) + * - User-friendly error messages + * + * ## Usage Example + * + * ```typescript + * import { createSchemaService } from '$lib/services/schema-service'; + * + * const schemaService = createSchemaService(); + * + * async function addPriorityField() { + * await schemaService.addField('task', { + * fieldName: 'priority', + * fieldType: 'number', + * indexed: false, + * required: false, + * default: 0, + * description: 'Task priority level' + * }); + * } + * + * async function loadTaskSchema() { + * const schema = await schemaService.getSchema('task'); + * console.log('Task schema version:', schema.version); + * } + * ``` + * + * ## Architecture + * + * - Frontend Service (this file) → Tauri IPC → MCP Tools → SchemaService → NodeService → Database + * - All operations enforce protection levels + * - Schema version automatically incremented on changes + * - Stateless design (no caching) - backend is fast enough for current needs + * + * @see packages/core/src/services/schema_service.rs - Rust implementation + * @see packages/core/src/mcp/handlers/schema.rs - MCP tool handlers + */ + +import { invoke } from '@tauri-apps/api/core'; +import { + SchemaOperationError, + type SchemaDefinition, + type AddFieldConfig, + type AddFieldResult, + type RemoveFieldResult, + type ExtendEnumResult, + type RemoveEnumValueResult +} from '$lib/types/schema'; + +/** + * Helper function to convert backend errors to user-friendly messages + */ +function formatSchemaError(error: unknown, operation: string, schemaId: string): string { + if (typeof error === 'string') { + return error; + } + + if (error instanceof Error) { + // Extract meaningful error messages from backend errors + const message = error.message; + + // Common error patterns with user-friendly translations + if (message.includes('not found')) { + return `Schema '${schemaId}' not found. Please verify the schema ID.`; + } + + if (message.includes('already exists')) { + return `Field already exists in schema '${schemaId}'. Field names must be unique.`; + } + + if (message.includes('protection')) { + return `Cannot ${operation} protected field. Only user-defined fields can be modified.`; + } + + if (message.includes('core value')) { + return `Cannot remove core enum values. Only user-added values can be removed.`; + } + + if (message.includes('not an enum')) { + return `Field is not an enum type. Enum operations only work on enum fields.`; + } + + if (message.includes('not extensible')) { + return `Enum field is not extensible. Cannot add new values to this enum.`; + } + + return message; + } + + return `Unknown error during ${operation} operation on schema '${schemaId}'`; +} + +/** + * Schema Service class + * + * Wraps Rust backend MCP schema tools with TypeScript interface + */ +export class SchemaService { + /** + * Get a schema definition by schema ID + * + * @param schemaId - Schema ID (e.g., "task", "person") + * @returns Complete schema definition with fields and metadata + * @throws {SchemaOperationError} If schema not found or retrieval fails + * + * @example + * ```typescript + * const schema = await service.getSchema('task'); + * console.log(`Task schema version ${schema.version} has ${schema.fields.length} fields`); + * ``` + */ + async getSchema(schemaId: string): Promise { + // Validate input before expensive IPC call + if (!schemaId || schemaId.trim() === '') { + throw new SchemaOperationError('Schema ID cannot be empty', schemaId, 'get'); + } + + try { + const result = await invoke<{ schema: unknown; schemaId: string; success: boolean }>( + 'mcp_call_tool', + { + name: 'get_schema_definition', + arguments: { schema_id: schemaId } + } + ); + + if (!result.success || !result.schema) { + throw new Error(`Failed to retrieve schema '${schemaId}'`); + } + + // Convert from snake_case (Rust) to camelCase (TypeScript) + const schema = result.schema as { + is_core: boolean; + version: number; + description: string; + fields: Array<{ + name: string; + type: string; + protection: string; + core_values?: string[]; + user_values?: string[]; + indexed: boolean; + required?: boolean; + extensible?: boolean; + default?: unknown; + description?: string; + item_type?: string; + }>; + }; + + return { + isCore: schema.is_core, + version: schema.version, + description: schema.description, + fields: schema.fields.map((field) => ({ + name: field.name, + type: field.type, + protection: field.protection as 'core' | 'user' | 'system', + coreValues: field.core_values, + userValues: field.user_values, + indexed: field.indexed, + required: field.required, + extensible: field.extensible, + default: field.default, + description: field.description, + itemType: field.item_type + })) + }; + } catch (error) { + const message = formatSchemaError(error, 'get', schemaId); + throw new SchemaOperationError(message, schemaId, 'get', error); + } + } + + /** + * Add a new field to a schema + * + * Only user-protected fields can be added. Core and system fields cannot be + * added through the schema service. The schema version is automatically incremented. + * + * @param schemaId - Schema ID to modify (e.g., "task") + * @param config - Field configuration + * @returns Result with new schema version + * @throws {SchemaOperationError} If field already exists or validation fails + * + * @example + * ```typescript + * // Add a priority field to task schema + * const result = await service.addField('task', { + * fieldName: 'priority', + * fieldType: 'number', + * indexed: false, + * required: false, + * default: 0, + * description: 'Task priority level (0-10)' + * }); + * console.log(`Schema updated to version ${result.newVersion}`); + * ``` + */ + async addField(schemaId: string, config: AddFieldConfig): Promise { + // Validate input before expensive IPC call + if (!schemaId || schemaId.trim() === '') { + throw new SchemaOperationError('Schema ID cannot be empty', schemaId, 'addField'); + } + if (!config.fieldName || config.fieldName.trim() === '') { + throw new SchemaOperationError('Field name cannot be empty', schemaId, 'addField'); + } + if (!config.fieldType || config.fieldType.trim() === '') { + throw new SchemaOperationError('Field type cannot be empty', schemaId, 'addField'); + } + + try { + const result = await invoke<{ + schema_id: string; + new_version: number; + success: boolean; + }>('mcp_call_tool', { + name: 'add_schema_field', + arguments: { + schema_id: schemaId, + field_name: config.fieldName, + field_type: config.fieldType, + indexed: config.indexed ?? false, + required: config.required, + default: config.default, + description: config.description, + item_type: config.itemType, + enum_values: config.enumValues, + extensible: config.extensible + } + }); + + return { + schemaId: result.schema_id, + newVersion: result.new_version, + success: result.success + }; + } catch (error) { + const message = formatSchemaError(error, 'add field', schemaId); + throw new SchemaOperationError(message, schemaId, 'addField', error); + } + } + + /** + * Remove a field from a schema + * + * Only user-protected fields can be removed. Core and system fields are + * protected and cannot be removed. The schema version is automatically incremented. + * + * @param schemaId - Schema ID to modify + * @param fieldName - Name of the field to remove + * @returns Result with new schema version + * @throws {SchemaOperationError} If field is protected or not found + * + * @example + * ```typescript + * // Remove a custom field + * const result = await service.removeField('task', 'priority'); + * console.log(`Field removed, schema now at version ${result.newVersion}`); + * ``` + */ + async removeField(schemaId: string, fieldName: string): Promise { + // Validate input before expensive IPC call + if (!schemaId || schemaId.trim() === '') { + throw new SchemaOperationError('Schema ID cannot be empty', schemaId, 'removeField'); + } + if (!fieldName || fieldName.trim() === '') { + throw new SchemaOperationError('Field name cannot be empty', schemaId, 'removeField'); + } + + try { + const result = await invoke<{ + schema_id: string; + new_version: number; + success: boolean; + }>('mcp_call_tool', { + name: 'remove_schema_field', + arguments: { + schema_id: schemaId, + field_name: fieldName + } + }); + + return { + schemaId: result.schema_id, + newVersion: result.new_version, + success: result.success + }; + } catch (error) { + const message = formatSchemaError(error, 'remove field', schemaId); + throw new SchemaOperationError(message, schemaId, 'removeField', error); + } + } + + /** + * Extend an enum field with a new value + * + * Adds a value to the `user_values` array of an enum field. The field must + * be marked as `extensible = true`. Core values cannot be added through this + * method. The schema version is automatically incremented. + * + * @param schemaId - Schema ID to modify + * @param fieldName - Name of the enum field + * @param value - Value to add to user_values + * @returns Result with new schema version + * @throws {SchemaOperationError} If field is not an enum, not extensible, or value already exists + * + * @example + * ```typescript + * // Add "BLOCKED" status to task enum + * const result = await service.extendEnum('task', 'status', 'BLOCKED'); + * console.log(`Enum extended, schema now at version ${result.newVersion}`); + * ``` + */ + async extendEnum(schemaId: string, fieldName: string, value: string): Promise { + // Validate input before expensive IPC call + if (!schemaId || schemaId.trim() === '') { + throw new SchemaOperationError('Schema ID cannot be empty', schemaId, 'extendEnum'); + } + if (!fieldName || fieldName.trim() === '') { + throw new SchemaOperationError('Field name cannot be empty', schemaId, 'extendEnum'); + } + if (!value || value.trim() === '') { + throw new SchemaOperationError('Enum value cannot be empty', schemaId, 'extendEnum'); + } + + try { + const result = await invoke<{ + schema_id: string; + new_version: number; + success: boolean; + }>('mcp_call_tool', { + name: 'extend_schema_enum', + arguments: { + schema_id: schemaId, + field_name: fieldName, + value: value + } + }); + + return { + schemaId: result.schema_id, + newVersion: result.new_version, + success: result.success + }; + } catch (error) { + const message = formatSchemaError(error, 'extend enum', schemaId); + throw new SchemaOperationError(message, schemaId, 'extendEnum', error); + } + } + + /** + * Remove a value from an enum field + * + * Removes a value from the `user_values` array of an enum field. Core values + * cannot be removed - they are protected. The schema version is automatically incremented. + * + * @param schemaId - Schema ID to modify + * @param fieldName - Name of the enum field + * @param value - Value to remove from user_values + * @returns Result with new schema version + * @throws {SchemaOperationError} If value is a core value or not found in user_values + * + * @example + * ```typescript + * // Remove a custom status + * const result = await service.removeEnumValue('task', 'status', 'BLOCKED'); + * console.log(`Value removed, schema now at version ${result.newVersion}`); + * ``` + */ + async removeEnumValue( + schemaId: string, + fieldName: string, + value: string + ): Promise { + // Validate input before expensive IPC call + if (!schemaId || schemaId.trim() === '') { + throw new SchemaOperationError('Schema ID cannot be empty', schemaId, 'removeEnumValue'); + } + if (!fieldName || fieldName.trim() === '') { + throw new SchemaOperationError('Field name cannot be empty', schemaId, 'removeEnumValue'); + } + if (!value || value.trim() === '') { + throw new SchemaOperationError('Enum value cannot be empty', schemaId, 'removeEnumValue'); + } + + try { + const result = await invoke<{ + schema_id: string; + new_version: number; + success: boolean; + }>('mcp_call_tool', { + name: 'remove_schema_enum_value', + arguments: { + schema_id: schemaId, + field_name: fieldName, + value: value + } + }); + + return { + schemaId: result.schema_id, + newVersion: result.new_version, + success: result.success + }; + } catch (error) { + const message = formatSchemaError(error, 'remove enum value', schemaId); + throw new SchemaOperationError(message, schemaId, 'removeEnumValue', error); + } + } + + /** + * Get all enum values for a field (core + user values combined) + * + * Helper method to get the complete list of allowed enum values. + * + * @param schemaId - Schema ID + * @param fieldName - Enum field name + * @returns Array of all allowed values (core + user) + * @throws {SchemaOperationError} If schema or field not found + * + * @example + * ```typescript + * const statusValues = await service.getEnumValues('task', 'status'); + * // Returns: ['OPEN', 'IN_PROGRESS', 'DONE', 'BLOCKED'] + * ``` + */ + async getEnumValues(schemaId: string, fieldName: string): Promise { + const schema = await this.getSchema(schemaId); + const field = schema.fields.find((f) => f.name === fieldName); + + if (!field) { + throw new Error(`Field '${fieldName}' not found in schema '${schemaId}'`); + } + + if (field.type !== 'enum') { + throw new Error(`Field '${fieldName}' is not an enum field`); + } + + const values: string[] = []; + if (field.coreValues) { + values.push(...field.coreValues); + } + if (field.userValues) { + values.push(...field.userValues); + } + + return values; + } + + /** + * Check if a field can be deleted + * + * Helper method to determine if a field is user-protected (can be deleted). + * + * @param schemaId - Schema ID + * @param fieldName - Field name to check + * @returns true if field can be deleted, false otherwise + * @throws {SchemaOperationError} If schema or field not found + */ + async canDeleteField(schemaId: string, fieldName: string): Promise { + const schema = await this.getSchema(schemaId); + const field = schema.fields.find((f) => f.name === fieldName); + + if (!field) { + return false; + } + + return field.protection === 'user'; + } +} + +/** + * Factory function to create a new SchemaService instance + * + * @returns New SchemaService instance + * + * @example + * ```svelte + * + * ``` + */ +export function createSchemaService(): SchemaService { + return new SchemaService(); +} + +/** + * Singleton instance of SchemaService for convenience + * + * Use this if you don't need multiple service instances. + * + * @example + * ```svelte + * + * ``` + */ +export const schemaService = createSchemaService(); diff --git a/packages/desktop-app/src/lib/types/schema.ts b/packages/desktop-app/src/lib/types/schema.ts new file mode 100644 index 000000000..777e5e492 --- /dev/null +++ b/packages/desktop-app/src/lib/types/schema.ts @@ -0,0 +1,192 @@ +/** + * Schema Management Types + * + * TypeScript definitions for the schema system. These types mirror the Rust + * backend schema definitions exactly to ensure type safety across the stack. + * + * ## Schema Protection Levels + * + * - `core`: Cannot be modified or deleted (UI components depend on these fields) + * - `user`: Fully modifiable/deletable by users + * - `system`: Auto-managed internal fields, read-only + * + * @see packages/core/src/models/schema.rs - Rust definitions + */ + +/** + * Protection level for schema fields + * + * Determines whether a field can be modified or deleted by users. + */ +export type ProtectionLevel = 'core' | 'user' | 'system'; + +/** + * Definition of a single field in a schema + * + * Supports various field types including primitives, enums, arrays, and objects. + * Enum fields can have protected core values and user-extensible values. + */ +export interface SchemaField { + /** Field name (must be unique within schema) */ + name: string; + + /** Field type (e.g., "string", "number", "boolean", "enum", "array", "object") */ + type: string; + + /** Protection level determining mutability */ + protection: ProtectionLevel; + + /** Protected enum values (cannot be removed) - enum fields only */ + coreValues?: string[]; + + /** User-extensible enum values (can be added/removed) - enum fields only */ + userValues?: string[]; + + /** Whether this field should be indexed for faster queries */ + indexed: boolean; + + /** Whether this field is required (cannot be null/undefined) */ + required?: boolean; + + /** Whether enum values can be extended by users */ + extensible?: boolean; + + /** Default value for the field */ + default?: unknown; + + /** Human-readable field description */ + description?: string; + + /** For array fields, the type of items in the array */ + itemType?: string; +} + +/** + * Complete schema definition + * + * Stored as a node with `node_type = "schema"` and `id = type_name`. + */ +export interface SchemaDefinition { + /** Whether this is a core schema (protected) */ + isCore: boolean; + + /** Schema version (increments on changes) */ + version: number; + + /** Human-readable schema description */ + description: string; + + /** Array of field definitions */ + fields: SchemaField[]; +} + +/** + * Configuration for adding a new field to a schema + * + * Used when calling `SchemaService.addField()`. Only user-protected + * fields can be added through the schema service. + */ +export interface AddFieldConfig { + /** Field name */ + fieldName: string; + + /** Field type (e.g., "string", "number", "boolean", "enum", "array") */ + fieldType: string; + + /** Whether this field should be indexed */ + indexed?: boolean; + + /** Whether this field is required */ + required?: boolean; + + /** Default value for the field */ + default?: unknown; + + /** Field description */ + description?: string; + + /** For array fields, the type of items in the array */ + itemType?: string; + + /** For enum fields, the allowed values (added to user_values) */ + enumValues?: string[]; + + /** For enum fields, whether users can extend with more values */ + extensible?: boolean; +} + +/** + * Result of adding a field to a schema + */ +export interface AddFieldResult { + /** ID of the modified schema */ + schemaId: string; + + /** New schema version after adding the field */ + newVersion: number; + + /** Operation success flag */ + success: boolean; +} + +/** + * Result of removing a field from a schema + */ +export interface RemoveFieldResult { + /** ID of the modified schema */ + schemaId: string; + + /** New schema version after removing the field */ + newVersion: number; + + /** Operation success flag */ + success: boolean; +} + +/** + * Result of extending an enum field + */ +export interface ExtendEnumResult { + /** ID of the modified schema */ + schemaId: string; + + /** New schema version after extending the enum */ + newVersion: number; + + /** Operation success flag */ + success: boolean; +} + +/** + * Result of removing an enum value + */ +export interface RemoveEnumValueResult { + /** ID of the modified schema */ + schemaId: string; + + /** New schema version after removing the value */ + newVersion: number; + + /** Operation success flag */ + success: boolean; +} + +/** + * Custom error for schema operations + */ +export class SchemaOperationError extends Error { + constructor( + message: string, + public readonly schemaId: string, + public readonly operation: string, + public readonly originalError?: unknown + ) { + super(message); + this.name = 'SchemaOperationError'; + + // Maintain proper stack trace (only available in V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, SchemaOperationError); + } + } +} diff --git a/packages/desktop-app/src/tests/unit/schema-service.test.ts b/packages/desktop-app/src/tests/unit/schema-service.test.ts new file mode 100644 index 000000000..91308e65d --- /dev/null +++ b/packages/desktop-app/src/tests/unit/schema-service.test.ts @@ -0,0 +1,545 @@ +/** + * Schema Service Tests + * + * Tests for the TypeScript schema service wrapper. Uses Vitest mocking + * to simulate Tauri IPC calls without requiring the full Rust backend. + * + * ## Test Coverage + * + * - Schema retrieval + * - Field addition (success and error cases) + * - Field removal (success and error cases) + * - Enum extension + * - Enum value removal + * - Helper methods (getEnumValues, canDeleteField) + * - Error handling and user-friendly messages + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SchemaService, createSchemaService } from '$lib/services/schema-service'; +import type { SchemaDefinition } from '$lib/types/schema'; + +// Mock Tauri's invoke function +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn() +})); + +import { invoke } from '@tauri-apps/api/core'; +const mockInvoke = vi.mocked(invoke); + +describe('SchemaService', () => { + let service: SchemaService; + + beforeEach(() => { + service = createSchemaService(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getSchema', () => { + it('should retrieve a schema definition', async () => { + const mockSchema = { + is_core: true, + version: 2, + description: 'Task tracking schema', + fields: [ + { + name: 'status', + type: 'enum', + protection: 'core', + core_values: ['OPEN', 'IN_PROGRESS', 'DONE'], + user_values: ['BLOCKED'], + indexed: true, + required: true, + extensible: true, + default: 'OPEN', + description: 'Task status' + }, + { + name: 'priority', + type: 'number', + protection: 'user', + indexed: false, + required: false, + default: 0, + description: 'Task priority' + } + ] + }; + + mockInvoke.mockResolvedValueOnce({ + schema: mockSchema, + schemaId: 'task', + success: true + }); + + const result = await service.getSchema('task'); + + expect(mockInvoke).toHaveBeenCalledWith('mcp_call_tool', { + name: 'get_schema_definition', + arguments: { schema_id: 'task' } + }); + + expect(result).toEqual({ + isCore: true, + version: 2, + description: 'Task tracking schema', + fields: [ + { + name: 'status', + type: 'enum', + protection: 'core', + coreValues: ['OPEN', 'IN_PROGRESS', 'DONE'], + userValues: ['BLOCKED'], + indexed: true, + required: true, + extensible: true, + default: 'OPEN', + description: 'Task status', + itemType: undefined + }, + { + name: 'priority', + type: 'number', + protection: 'user', + coreValues: undefined, + userValues: undefined, + indexed: false, + required: false, + extensible: undefined, + default: 0, + description: 'Task priority', + itemType: undefined + } + ] + }); + }); + + it('should throw user-friendly error when schema not found', async () => { + mockInvoke.mockRejectedValueOnce(new Error('Schema not found')); + + await expect(service.getSchema('nonexistent')).rejects.toThrow( + "Schema 'nonexistent' not found" + ); + }); + }); + + describe('addField', () => { + it('should add a new field to a schema', async () => { + mockInvoke.mockResolvedValueOnce({ + schema_id: 'task', + new_version: 3, + success: true + }); + + const result = await service.addField('task', { + fieldName: 'due_date', + fieldType: 'string', + indexed: false, + required: false, + description: 'Task due date' + }); + + expect(mockInvoke).toHaveBeenCalledWith('mcp_call_tool', { + name: 'add_schema_field', + arguments: { + schema_id: 'task', + field_name: 'due_date', + field_type: 'string', + indexed: false, + required: false, + default: undefined, + description: 'Task due date', + item_type: undefined, + enum_values: undefined, + extensible: undefined + } + }); + + expect(result).toEqual({ + schemaId: 'task', + newVersion: 3, + success: true + }); + }); + + it('should handle field already exists error', async () => { + mockInvoke.mockRejectedValueOnce( + new Error("Field 'priority' already exists in schema 'task'") + ); + + await expect( + service.addField('task', { + fieldName: 'priority', + fieldType: 'number' + }) + ).rejects.toThrow('Field already exists in schema'); + }); + + it('should add enum field with values', async () => { + mockInvoke.mockResolvedValueOnce({ + schema_id: 'task', + new_version: 3, + success: true + }); + + await service.addField('task', { + fieldName: 'category', + fieldType: 'enum', + enumValues: ['BUG', 'FEATURE', 'ENHANCEMENT'], + extensible: true, + required: false, + default: 'FEATURE' + }); + + expect(mockInvoke).toHaveBeenCalledWith('mcp_call_tool', { + name: 'add_schema_field', + arguments: { + schema_id: 'task', + field_name: 'category', + field_type: 'enum', + indexed: false, + required: false, + default: 'FEATURE', + description: undefined, + item_type: undefined, + enum_values: ['BUG', 'FEATURE', 'ENHANCEMENT'], + extensible: true + } + }); + }); + }); + + describe('removeField', () => { + it('should remove a user field from schema', async () => { + mockInvoke.mockResolvedValueOnce({ + schema_id: 'task', + new_version: 3, + success: true + }); + + const result = await service.removeField('task', 'priority'); + + expect(mockInvoke).toHaveBeenCalledWith('mcp_call_tool', { + name: 'remove_schema_field', + arguments: { + schema_id: 'task', + field_name: 'priority' + } + }); + + expect(result).toEqual({ + schemaId: 'task', + newVersion: 3, + success: true + }); + }); + + it('should throw error when removing core field', async () => { + mockInvoke.mockRejectedValueOnce( + new Error("Cannot remove field 'status' with protection level Core") + ); + + await expect(service.removeField('task', 'status')).rejects.toThrow( + 'Cannot remove field protected field' + ); + }); + + it('should throw error when field not found', async () => { + mockInvoke.mockRejectedValueOnce(new Error("Field 'nonexistent' not found in schema 'task'")); + + await expect(service.removeField('task', 'nonexistent')).rejects.toThrow('not found'); + }); + }); + + describe('extendEnum', () => { + it('should add value to enum field', async () => { + mockInvoke.mockResolvedValueOnce({ + schema_id: 'task', + new_version: 3, + success: true + }); + + const result = await service.extendEnum('task', 'status', 'BLOCKED'); + + expect(mockInvoke).toHaveBeenCalledWith('mcp_call_tool', { + name: 'extend_schema_enum', + arguments: { + schema_id: 'task', + field_name: 'status', + value: 'BLOCKED' + } + }); + + expect(result).toEqual({ + schemaId: 'task', + newVersion: 3, + success: true + }); + }); + + it('should throw error when field is not enum', async () => { + mockInvoke.mockRejectedValueOnce(new Error("Field 'priority' is not an enum (type: number)")); + + await expect(service.extendEnum('task', 'priority', 'HIGH')).rejects.toThrow('not an enum'); + }); + + it('should throw error when enum not extensible', async () => { + mockInvoke.mockRejectedValueOnce(new Error("Enum field 'status' is not extensible")); + + await expect(service.extendEnum('task', 'status', 'ARCHIVED')).rejects.toThrow( + 'not extensible' + ); + }); + + it('should throw error when value already exists', async () => { + mockInvoke.mockRejectedValueOnce( + new Error("Value 'BLOCKED' already exists in enum 'status'") + ); + + await expect(service.extendEnum('task', 'status', 'BLOCKED')).rejects.toThrow( + 'already exists' + ); + }); + }); + + describe('removeEnumValue', () => { + it('should remove user value from enum', async () => { + mockInvoke.mockResolvedValueOnce({ + schema_id: 'task', + new_version: 3, + success: true + }); + + const result = await service.removeEnumValue('task', 'status', 'BLOCKED'); + + expect(mockInvoke).toHaveBeenCalledWith('mcp_call_tool', { + name: 'remove_schema_enum_value', + arguments: { + schema_id: 'task', + field_name: 'status', + value: 'BLOCKED' + } + }); + + expect(result).toEqual({ + schemaId: 'task', + newVersion: 3, + success: true + }); + }); + + it('should throw error when removing core value', async () => { + mockInvoke.mockRejectedValueOnce( + new Error("Cannot remove core value 'OPEN' from enum 'status'") + ); + + await expect(service.removeEnumValue('task', 'status', 'OPEN')).rejects.toThrow( + 'Cannot remove core enum values' + ); + }); + + it('should throw error when value not found', async () => { + mockInvoke.mockRejectedValueOnce( + new Error("Value 'ARCHIVED' not found in user values of enum 'status'") + ); + + await expect(service.removeEnumValue('task', 'status', 'ARCHIVED')).rejects.toThrow( + 'not found' + ); + }); + }); + + describe('getEnumValues', () => { + it('should return all enum values (core + user)', async () => { + const mockSchema: SchemaDefinition = { + isCore: true, + version: 2, + description: 'Task schema', + fields: [ + { + name: 'status', + type: 'enum', + protection: 'core', + coreValues: ['OPEN', 'IN_PROGRESS', 'DONE'], + userValues: ['BLOCKED', 'WAITING'], + indexed: true, + required: true + } + ] + }; + + mockInvoke.mockResolvedValueOnce({ + schema: { + is_core: mockSchema.isCore, + version: mockSchema.version, + description: mockSchema.description, + fields: [ + { + name: 'status', + type: 'enum', + protection: 'core', + core_values: ['OPEN', 'IN_PROGRESS', 'DONE'], + user_values: ['BLOCKED', 'WAITING'], + indexed: true, + required: true + } + ] + }, + schemaId: 'task', + success: true + }); + + const values = await service.getEnumValues('task', 'status'); + + expect(values).toEqual(['OPEN', 'IN_PROGRESS', 'DONE', 'BLOCKED', 'WAITING']); + }); + + it('should throw error when field not found', async () => { + mockInvoke.mockResolvedValueOnce({ + schema: { + is_core: true, + version: 1, + description: 'Task schema', + fields: [] + }, + schemaId: 'task', + success: true + }); + + await expect(service.getEnumValues('task', 'nonexistent')).rejects.toThrow( + "Field 'nonexistent' not found" + ); + }); + + it('should throw error when field is not enum', async () => { + mockInvoke.mockResolvedValueOnce({ + schema: { + is_core: true, + version: 1, + description: 'Task schema', + fields: [ + { + name: 'priority', + type: 'number', + protection: 'user', + indexed: false + } + ] + }, + schemaId: 'task', + success: true + }); + + await expect(service.getEnumValues('task', 'priority')).rejects.toThrow('not an enum field'); + }); + }); + + describe('canDeleteField', () => { + it('should return true for user-protected fields', async () => { + mockInvoke.mockResolvedValueOnce({ + schema: { + is_core: true, + version: 1, + description: 'Task schema', + fields: [ + { + name: 'priority', + type: 'number', + protection: 'user', + indexed: false + } + ] + }, + schemaId: 'task', + success: true + }); + + const canDelete = await service.canDeleteField('task', 'priority'); + + expect(canDelete).toBe(true); + }); + + it('should return false for core-protected fields', async () => { + mockInvoke.mockResolvedValueOnce({ + schema: { + is_core: true, + version: 1, + description: 'Task schema', + fields: [ + { + name: 'status', + type: 'enum', + protection: 'core', + indexed: true + } + ] + }, + schemaId: 'task', + success: true + }); + + const canDelete = await service.canDeleteField('task', 'status'); + + expect(canDelete).toBe(false); + }); + + it('should return false when field not found', async () => { + mockInvoke.mockResolvedValueOnce({ + schema: { + is_core: true, + version: 1, + description: 'Task schema', + fields: [] + }, + schemaId: 'task', + success: true + }); + + const canDelete = await service.canDeleteField('task', 'nonexistent'); + + expect(canDelete).toBe(false); + }); + }); + + describe('error handling', () => { + it('should format validation errors', async () => { + mockInvoke.mockRejectedValueOnce( + new Error('Cannot add core-protected fields. Field has protection: Core') + ); + + await expect( + service.addField('task', { + fieldName: 'test', + fieldType: 'string' + }) + ).rejects.toThrow('Cannot add field protected field'); + }); + + it('should format unknown errors', async () => { + mockInvoke.mockRejectedValueOnce('Unexpected error string'); + + await expect(service.getSchema('task')).rejects.toThrow('Unexpected error string'); + }); + + it('should handle non-Error objects', async () => { + mockInvoke.mockRejectedValueOnce({ someProperty: 'value' }); + + await expect(service.getSchema('task')).rejects.toThrow('Unknown error'); + }); + }); + + describe('factory and singleton', () => { + it('should create new service instance via factory', () => { + const service1 = createSchemaService(); + const service2 = createSchemaService(); + + expect(service1).toBeInstanceOf(SchemaService); + expect(service2).toBeInstanceOf(SchemaService); + expect(service1).not.toBe(service2); + }); + }); +});