diff --git a/packages/desktop-app/src-tauri/src/bin/dev-server.rs b/packages/desktop-app/src-tauri/src/bin/dev-server.rs index 1ae94a443..9e1e8f503 100644 --- a/packages/desktop-app/src-tauri/src/bin/dev-server.rs +++ b/packages/desktop-app/src-tauri/src/bin/dev-server.rs @@ -39,7 +39,7 @@ use std::env; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use nodespace_core::{DatabaseService, NodeService}; @@ -88,9 +88,12 @@ async fn main() -> anyhow::Result<()> { tracing::info!("✅ Services initialized"); + // Wrap services in RwLock for dynamic database switching during tests + let db_arc = Arc::new(RwLock::new(Arc::new(db_service))); + let ns_arc = Arc::new(RwLock::new(Arc::new(node_service))); + // Start HTTP server - nodespace_app_lib::dev_server::start_server(Arc::new(db_service), Arc::new(node_service), port) - .await?; + nodespace_app_lib::dev_server::start_server(db_arc, ns_arc, port).await?; Ok(()) } diff --git a/packages/desktop-app/src-tauri/src/dev_server/embedding_endpoints.rs b/packages/desktop-app/src-tauri/src/dev_server/embedding_endpoints.rs index 8e4da5169..eb580c785 100644 --- a/packages/desktop-app/src-tauri/src/dev_server/embedding_endpoints.rs +++ b/packages/desktop-app/src-tauri/src/dev_server/embedding_endpoints.rs @@ -562,16 +562,15 @@ async fn create_container_node( mentioned_by: Vec::new(), // Will be computed from node_mentions table }; - state - .node_service + let node_service = state.node_service.read().unwrap().clone(); + node_service .create_node(container_node) .await .map_err(|e| HttpError::from_anyhow(e.into(), "NODE_SERVICE_ERROR"))?; // If mentioned_by is provided, create mention relationship if let Some(mentioning_node_id) = input.mentioned_by { - state - .node_service + node_service .create_mention(&mentioning_node_id, &node_id) .await .map_err(|e| HttpError::from_anyhow(e.into(), "NODE_SERVICE_ERROR"))?; @@ -607,8 +606,8 @@ async fn create_node_mention( State(state): State, Json(payload): Json, ) -> Result { - state - .node_service + let node_service = state.node_service.read().unwrap().clone(); + node_service .create_mention(&payload.mentioning_node_id, &payload.mentioned_node_id) .await .map_err(|e| HttpError::from_anyhow(e.into(), "NODE_SERVICE_ERROR"))?; diff --git a/packages/desktop-app/src-tauri/src/dev_server/mod.rs b/packages/desktop-app/src-tauri/src/dev_server/mod.rs index 07dd88f56..1f35eb0ce 100644 --- a/packages/desktop-app/src-tauri/src/dev_server/mod.rs +++ b/packages/desktop-app/src-tauri/src/dev_server/mod.rs @@ -33,7 +33,7 @@ use axum::{ http::{header, Method}, Router, }; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use tower_http::cors::{Any, CorsLayer}; use nodespace_core::{DatabaseService, NodeService}; @@ -54,10 +54,15 @@ mod http_error; pub use http_error::HttpError; /// Application state shared across all endpoints +/// +/// Uses RwLock to allow dynamic database switching for test isolation. +/// Each test can call /api/database/init with a unique database path, +/// and the init endpoint will replace the NodeService with a new instance +/// connected to the test database. #[derive(Clone)] pub struct AppState { - pub db: Arc, - pub node_service: Arc, + pub db: Arc>>, + pub node_service: Arc>>, } /// Create the main application router with all endpoint modules @@ -137,8 +142,8 @@ fn cors_layer() -> CorsLayer { /// /// Returns error if server fails to bind or start. pub async fn start_server( - db: Arc, - node_service: Arc, + db: Arc>>, + node_service: Arc>>, port: u16, ) -> anyhow::Result<()> { let state = AppState { db, node_service }; diff --git a/packages/desktop-app/src-tauri/src/dev_server/node_endpoints.rs b/packages/desktop-app/src-tauri/src/dev_server/node_endpoints.rs index 93d4103d8..63ccc8971 100644 --- a/packages/desktop-app/src-tauri/src/dev_server/node_endpoints.rs +++ b/packages/desktop-app/src-tauri/src/dev_server/node_endpoints.rs @@ -22,6 +22,7 @@ use axum::{ Router, }; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use crate::commands::nodes::CreateNodeInput; use crate::dev_server::{AppState, HttpError}; @@ -88,7 +89,7 @@ async fn health_check() -> Json { /// The actual database initialization happens when the dev-server binary starts. /// Tests should ensure the dev-server is running before calling this endpoint. async fn init_database( - State(_state): State, + State(state): State, Query(params): Query, ) -> Result, HttpError> { use std::path::PathBuf; @@ -120,13 +121,26 @@ async fn init_database( .ok_or_else(|| HttpError::new("Invalid database path", "PATH_ERROR"))? .to_string(); - // Actually initialize the database using the existing DatabaseService - use nodespace_core::DatabaseService; - let _ = DatabaseService::new(db_path.clone()) + // Create new DatabaseService and NodeService for this database + use nodespace_core::{DatabaseService, NodeService}; + let new_db = DatabaseService::new(db_path.clone()) .await .map_err(|e| HttpError::from_anyhow(e.into(), "DATABASE_INIT_ERROR"))?; - tracing::info!("📦 Database initialized at: {}", db_path_str); + let new_node_service = NodeService::new(new_db.clone()) + .map_err(|e| HttpError::from_anyhow(e.into(), "NODE_SERVICE_INIT_ERROR"))?; + + // Replace the services in AppState using RwLock + { + let mut db_lock = state.db.write().unwrap(); + *db_lock = Arc::new(new_db); + } + { + let mut ns_lock = state.node_service.write().unwrap(); + *ns_lock = Arc::new(new_node_service); + } + + tracing::info!("🔄 Database SWAPPED to: {}", db_path_str); Ok(Json(InitDbResponse { db_path: db_path_str, @@ -192,11 +206,15 @@ async fn create_node( mentioned_by: Vec::new(), }; - state - .node_service + // Access node_service through RwLock + let node_service = state.node_service.read().unwrap().clone(); + node_service .create_node(full_node) .await - .map_err(|e| HttpError::from_anyhow(e.into(), "NODE_SERVICE_ERROR"))?; + .map_err(|e| { + tracing::error!("❌ Node creation failed for {}: {:?}", node.id, e); + HttpError::from_anyhow(e.into(), "NODE_SERVICE_ERROR") + })?; tracing::debug!("✅ Created node: {}", node.id); @@ -218,8 +236,8 @@ async fn get_node( State(state): State, Path(id): Path, ) -> Result>, HttpError> { - let node = state - .node_service + let node_service = state.node_service.read().unwrap().clone(); + let node = node_service .get_node(&id) .await .map_err(|e| HttpError::from_anyhow(e.into(), "NODE_SERVICE_ERROR"))?; @@ -249,8 +267,8 @@ async fn update_node( Path(id): Path, Json(update): Json, ) -> Result { - state - .node_service + let node_service = state.node_service.read().unwrap().clone(); + node_service .update_node(&id, update) .await .map_err(|e| HttpError::from_anyhow(e.into(), "NODE_SERVICE_ERROR"))?; @@ -275,8 +293,8 @@ async fn delete_node( State(state): State, Path(id): Path, ) -> Result { - state - .node_service + let node_service = state.node_service.read().unwrap().clone(); + node_service .delete_node(&id) .await .map_err(|e| HttpError::from_anyhow(e.into(), "NODE_SERVICE_ERROR"))?; @@ -301,8 +319,8 @@ async fn get_children( State(state): State, Path(parent_id): Path, ) -> Result>, HttpError> { - let children = state - .node_service + let node_service = state.node_service.read().unwrap().clone(); + let children = node_service .get_children(&parent_id) .await .map_err(|e| HttpError::from_anyhow(e.into(), "NODE_SERVICE_ERROR"))?; diff --git a/packages/desktop-app/src-tauri/src/dev_server/query_endpoints.rs b/packages/desktop-app/src-tauri/src/dev_server/query_endpoints.rs index a3e2ece5e..10b47b2da 100644 --- a/packages/desktop-app/src-tauri/src/dev_server/query_endpoints.rs +++ b/packages/desktop-app/src-tauri/src/dev_server/query_endpoints.rs @@ -145,8 +145,8 @@ async fn query_nodes_simple( // Execute query with timing let start = std::time::Instant::now(); - let nodes = state - .node_service + let node_service = state.node_service.read().unwrap().clone(); + let nodes = node_service .query_nodes(filter.clone()) .await .map_err(|e| HttpError::from_anyhow(e.into(), "NODE_QUERY_ERROR"))?; diff --git a/packages/desktop-app/src/lib/services/reactive-node-service.svelte.ts b/packages/desktop-app/src/lib/services/reactive-node-service.svelte.ts index d10aa0a6a..e4c4ec79a 100644 --- a/packages/desktop-app/src/lib/services/reactive-node-service.svelte.ts +++ b/packages/desktop-app/src/lib/services/reactive-node-service.svelte.ts @@ -374,15 +374,15 @@ export function createReactiveNodeService(events: NodeManagerEvents) { beforeSiblingId = afterNodeId; } - // Determine origin_node_id - inherit from parent or use own id if no parent + // Determine origin_node_id - inherit from parent or use 'root' if no parent let rootId: string; if (newParentId) { const parent = sharedNodeStore.getNode(newParentId); // Inherit origin_node_id from parent, or use parent's id if parent has no origin_node_id rootId = parent?.containerNodeId || newParentId; } else { - // No parent means this node is the root - rootId = nodeId; + // No parent means this node is at root level + rootId = 'root'; } // Create Node with unified type system diff --git a/packages/desktop-app/src/lib/services/shared-node-store.ts b/packages/desktop-app/src/lib/services/shared-node-store.ts index 5d8416765..2cc49137d 100644 --- a/packages/desktop-app/src/lib/services/shared-node-store.ts +++ b/packages/desktop-app/src/lib/services/shared-node-store.ts @@ -288,7 +288,10 @@ export class SharedNodeStore { this.metrics.updateCount++; // Phase 2.4: Persist to database (unless skipped) - if (!options.skipPersistence && source.type !== 'database') { + // IMPORTANT: Skip viewer-sourced updates - BaseNodeViewer handles persistence with debouncing + // Only persist updates from other sources (e.g., MCP server in the future) + // TODO: Refactor BaseNodeViewer to use SharedNodeStore for all persistence (#TBD) + if (!options.skipPersistence && source.type !== 'database' && source.type !== 'viewer') { // Skip persisting empty text nodes - they exist in UI but not in database const isEmptyTextNode = updatedNode.nodeType === 'text' && updatedNode.content.trim() === ''; @@ -368,7 +371,10 @@ export class SharedNodeStore { } // Phase 2.4: Persist to database - if (!skipPersistence && source.type !== 'database') { + // IMPORTANT: Skip viewer-sourced updates - BaseNodeViewer handles persistence with debouncing + // Only persist updates from other sources (e.g., MCP server in the future) + // TODO: Refactor BaseNodeViewer to use SharedNodeStore for all persistence (#TBD) + if (!skipPersistence && source.type !== 'database' && source.type !== 'viewer') { // Skip persisting empty text nodes - they exist in UI but not in database // until user adds content (backend validation requires non-empty content) const isEmptyTextNode = node.nodeType === 'text' && node.content.trim() === ''; @@ -1238,6 +1244,28 @@ export class SharedNodeStore { clearTestErrors(): void { this.testErrors = []; } + + /** + * Reset store state (for testing only) + * @internal + */ + __resetForTesting(): void { + this.nodes.clear(); + this.persistedNodeIds.clear(); + this.subscriptions.clear(); + this.wildcardSubscriptions.clear(); + this.pendingUpdates.clear(); + this.versions.clear(); + this.testErrors = []; + this.metrics = { + updateCount: 0, + avgUpdateTime: 0, + maxUpdateTime: 0, + subscriptionCount: 0, + conflictCount: 0, + rollbackCount: 0 + }; + } } // ============================================================================ diff --git a/packages/desktop-app/src/tests/integration/node-ordering.test.ts b/packages/desktop-app/src/tests/integration/node-ordering.test.ts index 81e7faf21..a58f3f2aa 100644 --- a/packages/desktop-app/src/tests/integration/node-ordering.test.ts +++ b/packages/desktop-app/src/tests/integration/node-ordering.test.ts @@ -30,6 +30,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createReactiveNodeService } from '$lib/services/reactive-node-service.svelte'; +import { sharedNodeStore } from '$lib/services/shared-node-store'; import { createTestNode } from '../helpers'; describe('Node Ordering Integration Tests', () => { @@ -43,6 +44,8 @@ describe('Node Ordering Integration Tests', () => { }; beforeEach(() => { + // Reset singleton state between tests to prevent contamination + sharedNodeStore.__resetForTesting(); nodeService = createReactiveNodeService(mockEvents); });