Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/desktop-app/src-tauri/src/bin/dev-server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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(())
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"))?;
Expand Down Expand Up @@ -607,8 +606,8 @@ async fn create_node_mention(
State(state): State<AppState>,
Json(payload): Json<CreateMentionRequest>,
) -> Result<StatusCode, HttpError> {
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"))?;
Expand Down
15 changes: 10 additions & 5 deletions packages/desktop-app/src-tauri/src/dev_server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<DatabaseService>,
pub node_service: Arc<NodeService>,
pub db: Arc<RwLock<Arc<DatabaseService>>>,
pub node_service: Arc<RwLock<Arc<NodeService>>>,
}

/// Create the main application router with all endpoint modules
Expand Down Expand Up @@ -137,8 +142,8 @@ fn cors_layer() -> CorsLayer {
///
/// Returns error if server fails to bind or start.
pub async fn start_server(
db: Arc<DatabaseService>,
node_service: Arc<NodeService>,
db: Arc<RwLock<Arc<DatabaseService>>>,
node_service: Arc<RwLock<Arc<NodeService>>>,
port: u16,
) -> anyhow::Result<()> {
let state = AppState { db, node_service };
Expand Down
50 changes: 34 additions & 16 deletions packages/desktop-app/src-tauri/src/dev_server/node_endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -88,7 +89,7 @@ async fn health_check() -> Json<HealthStatus> {
/// 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<AppState>,
State(state): State<AppState>,
Query(params): Query<InitDbQuery>,
) -> Result<Json<InitDbResponse>, HttpError> {
use std::path::PathBuf;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand All @@ -218,8 +236,8 @@ async fn get_node(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Option<Node>>, 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"))?;
Expand Down Expand Up @@ -249,8 +267,8 @@ async fn update_node(
Path(id): Path<String>,
Json(update): Json<NodeUpdate>,
) -> Result<StatusCode, HttpError> {
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"))?;
Expand All @@ -275,8 +293,8 @@ async fn delete_node(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, HttpError> {
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"))?;
Expand All @@ -301,8 +319,8 @@ async fn get_children(
State(state): State<AppState>,
Path(parent_id): Path<String>,
) -> Result<Json<Vec<Node>>, 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"))?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 30 additions & 2 deletions packages/desktop-app/src/lib/services/shared-node-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() === '';
Expand Down Expand Up @@ -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() === '';
Expand Down Expand Up @@ -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
};
}
}

// ============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -43,6 +44,8 @@ describe('Node Ordering Integration Tests', () => {
};

beforeEach(() => {
// Reset singleton state between tests to prevent contamination
sharedNodeStore.__resetForTesting();
nodeService = createReactiveNodeService(mockEvents);
});

Expand Down