feat: Introduce smart-features Cargo feature to conditionally compile smart note functionalities and their dependencies.

This commit is contained in:
2025-12-26 00:48:03 +01:00
parent c8276ac306
commit c2a324b368
12 changed files with 144 additions and 60 deletions

View File

@@ -4,9 +4,25 @@ version = "0.1.0"
edition = "2024" edition = "2024"
default-run = "notes-api" default-run = "notes-api"
[features]
default = ["sqlite", "smart-features"]
sqlite = [
"notes-infra/sqlite",
"tower-sessions-sqlx-store/sqlite",
"sqlx/sqlite",
]
postgres = [
"notes-infra/postgres",
"tower-sessions-sqlx-store/postgres",
"sqlx/postgres",
]
smart-features = ["notes-infra/smart-features", "dep:async-nats"]
[dependencies] [dependencies]
notes-domain = { path = "../notes-domain" } notes-domain = { path = "../notes-domain" }
notes-infra = { path = "../notes-infra", features = ["sqlite"] } notes-infra = { path = "../notes-infra", default-features = false, features = [
"sqlite",
] }
# Web framework # Web framework
axum = { version = "0.8.8", features = ["macros"] } axum = { version = "0.8.8", features = ["macros"] }
@@ -20,7 +36,7 @@ tower-sessions-sqlx-store = { version = "0.15", features = ["sqlite"] }
password-auth = "1.0" password-auth = "1.0"
time = "0.3" time = "0.3"
async-trait = "0.1.89" async-trait = "0.1.89"
async-nats = "0.39" async-nats = { version = "0.39", optional = true }
# Async runtime # Async runtime
tokio = { version = "1.48.0", features = ["full"] } tokio = { version = "1.48.0", features = ["full"] }

View File

@@ -1,3 +1,4 @@
#[cfg(feature = "smart-features")]
use notes_infra::factory::{EmbeddingProvider, VectorProvider}; use notes_infra::factory::{EmbeddingProvider, VectorProvider};
use std::env; use std::env;
@@ -10,7 +11,9 @@ pub struct Config {
pub session_secret: String, pub session_secret: String,
pub cors_allowed_origins: Vec<String>, pub cors_allowed_origins: Vec<String>,
pub allow_registration: bool, pub allow_registration: bool,
#[cfg(feature = "smart-features")]
pub embedding_provider: EmbeddingProvider, pub embedding_provider: EmbeddingProvider,
#[cfg(feature = "smart-features")]
pub vector_provider: VectorProvider, pub vector_provider: VectorProvider,
pub broker_url: String, pub broker_url: String,
} }
@@ -25,7 +28,9 @@ impl Default for Config {
.to_string(), .to_string(),
cors_allowed_origins: vec!["http://localhost:5173".to_string()], cors_allowed_origins: vec!["http://localhost:5173".to_string()],
allow_registration: true, allow_registration: true,
#[cfg(feature = "smart-features")]
embedding_provider: EmbeddingProvider::FastEmbed, embedding_provider: EmbeddingProvider::FastEmbed,
#[cfg(feature = "smart-features")]
vector_provider: VectorProvider::Qdrant { vector_provider: VectorProvider::Qdrant {
url: "http://localhost:6334".to_string(), url: "http://localhost:6334".to_string(),
collection: "notes".to_string(), collection: "notes".to_string(),
@@ -66,11 +71,13 @@ impl Config {
.map(|s| s.to_lowercase() == "true") .map(|s| s.to_lowercase() == "true")
.unwrap_or(true); .unwrap_or(true);
#[cfg(feature = "smart-features")]
let embedding_provider = match env::var("EMBEDDING_PROVIDER").unwrap_or_default().as_str() { let embedding_provider = match env::var("EMBEDDING_PROVIDER").unwrap_or_default().as_str() {
// Future: "ollama" => EmbeddingProvider::Ollama(...), // Future: "ollama" => EmbeddingProvider::Ollama(...),
_ => EmbeddingProvider::FastEmbed, _ => EmbeddingProvider::FastEmbed,
}; };
#[cfg(feature = "smart-features")]
let vector_provider = match env::var("VECTOR_PROVIDER").unwrap_or_default().as_str() { let vector_provider = match env::var("VECTOR_PROVIDER").unwrap_or_default().as_str() {
// Future: "postgres" => ... // Future: "postgres" => ...
_ => VectorProvider::Qdrant { _ => VectorProvider::Qdrant {
@@ -89,7 +96,9 @@ impl Config {
session_secret, session_secret,
cors_allowed_origins, cors_allowed_origins,
allow_registration, allow_registration,
#[cfg(feature = "smart-features")]
embedding_provider, embedding_provider,
#[cfg(feature = "smart-features")]
vector_provider, vector_provider,
broker_url, broker_url,
} }

View File

@@ -43,9 +43,11 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Connecting to database: {}", config.database_url); tracing::info!("Connecting to database: {}", config.database_url);
let db_config = DatabaseConfig::new(&config.database_url); let db_config = DatabaseConfig::new(&config.database_url);
#[cfg(feature = "smart-features")]
use notes_infra::factory::build_link_repository;
use notes_infra::factory::{ use notes_infra::factory::{
build_database_pool, build_link_repository, build_note_repository, build_session_store, build_database_pool, build_note_repository, build_session_store, build_tag_repository,
build_tag_repository, build_user_repository, build_user_repository,
}; };
let pool = build_database_pool(&db_config) let pool = build_database_pool(&db_config)
.await .await
@@ -73,6 +75,7 @@ async fn main() -> anyhow::Result<()> {
let user_repo = build_user_repository(&pool) let user_repo = build_user_repository(&pool)
.await .await
.map_err(|e| anyhow::anyhow!(e))?; .map_err(|e| anyhow::anyhow!(e))?;
#[cfg(feature = "smart-features")]
let link_repo = build_link_repository(&pool) let link_repo = build_link_repository(&pool)
.await .await
.map_err(|e| anyhow::anyhow!(e))?; .map_err(|e| anyhow::anyhow!(e))?;
@@ -84,20 +87,26 @@ async fn main() -> anyhow::Result<()> {
let user_service = Arc::new(UserService::new(user_repo.clone())); let user_service = Arc::new(UserService::new(user_repo.clone()));
// Connect to NATS // Connect to NATS
tracing::info!("Connecting to NATS: {}", config.broker_url); // Connect to NATS
let nats_client = async_nats::connect(&config.broker_url) #[cfg(feature = "smart-features")]
.await let nats_client = {
.map_err(|e| anyhow::anyhow!("NATS connection failed: {}", e))?; tracing::info!("Connecting to NATS: {}", config.broker_url);
async_nats::connect(&config.broker_url)
.await
.map_err(|e| anyhow::anyhow!("NATS connection failed: {}", e))?
};
// Create application state // Create application state
let state = AppState::new( let state = AppState::new(
note_repo, note_repo,
tag_repo, tag_repo,
user_repo.clone(), user_repo.clone(),
#[cfg(feature = "smart-features")]
link_repo, link_repo,
note_service, note_service,
tag_service, tag_service,
user_service, user_service,
#[cfg(feature = "smart-features")]
nats_client, nats_client,
config.clone(), config.clone(),
); );

View File

@@ -15,7 +15,7 @@ use crate::state::AppState;
/// Create the API v1 router /// Create the API v1 router
pub fn api_v1_router() -> Router<AppState> { pub fn api_v1_router() -> Router<AppState> {
Router::new() let router = Router::new()
// Auth routes // Auth routes
.route("/auth/register", post(auth::register)) .route("/auth/register", post(auth::register))
.route("/auth/login", post(auth::login)) .route("/auth/login", post(auth::login))
@@ -29,8 +29,12 @@ pub fn api_v1_router() -> Router<AppState> {
.patch(notes::update_note) .patch(notes::update_note)
.delete(notes::delete_note), .delete(notes::delete_note),
) )
.route("/notes/{id}/versions", get(notes::list_note_versions)) .route("/notes/{id}/versions", get(notes::list_note_versions));
.route("/notes/{id}/related", get(notes::get_related_notes))
#[cfg(feature = "smart-features")]
let router = router.route("/notes/{id}/related", get(notes::get_related_notes));
router
// Search route // Search route
.route("/search", get(notes::search_notes)) .route("/search", get(notes::search_notes))
// Import/Export routes // Import/Export routes

View File

@@ -83,15 +83,18 @@ pub async fn create_note(
let note = state.note_service.create_note(domain_req).await?; let note = state.note_service.create_note(domain_req).await?;
// Publish event // Publish event
let payload = serde_json::to_vec(&note).unwrap_or_default(); #[cfg(feature = "smart-features")]
if let Err(e) = state
.nats_client
.publish("notes.updated", payload.into())
.await
{ {
tracing::error!("Failed to publish notes.updated event: {}", e); let payload = serde_json::to_vec(&note).unwrap_or_default();
} else { if let Err(e) = state
tracing::info!("Published notes.updated event for note {}", note.id); .nats_client
.publish("notes.updated", payload.into())
.await
{
tracing::error!("Failed to publish notes.updated event: {}", e);
} else {
tracing::info!("Published notes.updated event for note {}", note.id);
}
} }
Ok((StatusCode::CREATED, Json(NoteResponse::from(note)))) Ok((StatusCode::CREATED, Json(NoteResponse::from(note))))
@@ -150,15 +153,18 @@ pub async fn update_note(
let note = state.note_service.update_note(domain_req).await?; let note = state.note_service.update_note(domain_req).await?;
// Publish event // Publish event
let payload = serde_json::to_vec(&note).unwrap_or_default(); #[cfg(feature = "smart-features")]
if let Err(e) = state
.nats_client
.publish("notes.updated", payload.into())
.await
{ {
tracing::error!("Failed to publish notes.updated event: {}", e); let payload = serde_json::to_vec(&note).unwrap_or_default();
} else { if let Err(e) = state
tracing::info!("Published notes.updated event for note {}", note.id); .nats_client
.publish("notes.updated", payload.into())
.await
{
tracing::error!("Failed to publish notes.updated event: {}", e);
} else {
tracing::info!("Published notes.updated event for note {}", note.id);
}
} }
Ok(Json(NoteResponse::from(note))) Ok(Json(NoteResponse::from(note)))
@@ -228,6 +234,9 @@ pub async fn list_note_versions(
/// Get related notes /// Get related notes
/// GET /api/v1/notes/:id/related /// GET /api/v1/notes/:id/related
/// Get related notes
/// GET /api/v1/notes/:id/related
#[cfg(feature = "smart-features")]
pub async fn get_related_notes( pub async fn get_related_notes(
State(state): State<AppState>, State(state): State<AppState>,
auth: AuthSession<AuthBackend>, auth: AuthSession<AuthBackend>,

View File

@@ -11,10 +11,12 @@ pub struct AppState {
pub note_repo: Arc<dyn NoteRepository>, pub note_repo: Arc<dyn NoteRepository>,
pub tag_repo: Arc<dyn TagRepository>, pub tag_repo: Arc<dyn TagRepository>,
pub user_repo: Arc<dyn UserRepository>, pub user_repo: Arc<dyn UserRepository>,
#[cfg(feature = "smart-features")]
pub link_repo: Arc<dyn notes_domain::ports::LinkRepository>, pub link_repo: Arc<dyn notes_domain::ports::LinkRepository>,
pub note_service: Arc<NoteService>, pub note_service: Arc<NoteService>,
pub tag_service: Arc<TagService>, pub tag_service: Arc<TagService>,
pub user_service: Arc<UserService>, pub user_service: Arc<UserService>,
#[cfg(feature = "smart-features")]
pub nats_client: async_nats::Client, pub nats_client: async_nats::Client,
pub config: Config, pub config: Config,
} }
@@ -24,21 +26,23 @@ impl AppState {
note_repo: Arc<dyn NoteRepository>, note_repo: Arc<dyn NoteRepository>,
tag_repo: Arc<dyn TagRepository>, tag_repo: Arc<dyn TagRepository>,
user_repo: Arc<dyn UserRepository>, user_repo: Arc<dyn UserRepository>,
link_repo: Arc<dyn notes_domain::ports::LinkRepository>, #[cfg(feature = "smart-features")] link_repo: Arc<dyn notes_domain::ports::LinkRepository>,
note_service: Arc<NoteService>, note_service: Arc<NoteService>,
tag_service: Arc<TagService>, tag_service: Arc<TagService>,
user_service: Arc<UserService>, user_service: Arc<UserService>,
nats_client: async_nats::Client, #[cfg(feature = "smart-features")] nats_client: async_nats::Client,
config: Config, config: Config,
) -> Self { ) -> Self {
Self { Self {
note_repo, note_repo,
tag_repo, tag_repo,
user_repo, user_repo,
#[cfg(feature = "smart-features")]
link_repo, link_repo,
note_service, note_service,
tag_service, tag_service,
user_service, user_service,
#[cfg(feature = "smart-features")]
nats_client, nats_client,
config, config,
} }

View File

@@ -4,9 +4,10 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[features] [features]
default = ["sqlite"] default = ["sqlite", "smart-features"]
sqlite = ["sqlx/sqlite", "tower-sessions-sqlx-store/sqlite"] sqlite = ["sqlx/sqlite", "tower-sessions-sqlx-store/sqlite"]
postgres = ["sqlx/postgres", "tower-sessions-sqlx-store/postgres"] postgres = ["sqlx/postgres", "tower-sessions-sqlx-store/postgres"]
smart-features = ["dep:fastembed", "dep:qdrant-client"]
[dependencies] [dependencies]
notes-domain = { path = "../notes-domain" } notes-domain = { path = "../notes-domain" }
@@ -19,6 +20,6 @@ tracing = "0.1"
uuid = { version = "1.19.0", features = ["v4", "serde"] } uuid = { version = "1.19.0", features = ["v4", "serde"] }
tower-sessions = "0.14.0" tower-sessions = "0.14.0"
tower-sessions-sqlx-store = { version = "0.15.0", default-features = false } tower-sessions-sqlx-store = { version = "0.15.0", default-features = false }
fastembed = "5.4" fastembed = { version = "5.4", optional = true }
qdrant-client = "1.16" qdrant-client = { version = "1.16", optional = true }
serde_json = "1.0" serde_json = "1.0"

View File

@@ -17,6 +17,7 @@ pub enum FactoryError {
pub type FactoryResult<T> = Result<T, FactoryError>; pub type FactoryResult<T> = Result<T, FactoryError>;
#[cfg(feature = "smart-features")]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum EmbeddingProvider { pub enum EmbeddingProvider {
FastEmbed, FastEmbed,
@@ -24,12 +25,14 @@ pub enum EmbeddingProvider {
// OpenAI(String), // ApiKey // OpenAI(String), // ApiKey
} }
#[cfg(feature = "smart-features")]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum VectorProvider { pub enum VectorProvider {
Qdrant { url: String, collection: String }, Qdrant { url: String, collection: String },
// InMemory, // InMemory,
} }
#[cfg(feature = "smart-features")]
pub async fn build_embedding_generator( pub async fn build_embedding_generator(
provider: &EmbeddingProvider, provider: &EmbeddingProvider,
) -> FactoryResult<Arc<dyn notes_domain::ports::EmbeddingGenerator>> { ) -> FactoryResult<Arc<dyn notes_domain::ports::EmbeddingGenerator>> {
@@ -41,6 +44,7 @@ pub async fn build_embedding_generator(
} }
} }
#[cfg(feature = "smart-features")]
pub async fn build_vector_store( pub async fn build_vector_store(
provider: &VectorProvider, provider: &VectorProvider,
) -> FactoryResult<Arc<dyn notes_domain::ports::VectorStore>> { ) -> FactoryResult<Arc<dyn notes_domain::ports::VectorStore>> {

View File

@@ -15,6 +15,7 @@
//! - [`db::run_migrations`] - Run database migrations //! - [`db::run_migrations`] - Run database migrations
pub mod db; pub mod db;
#[cfg(feature = "smart-features")]
pub mod embeddings; pub mod embeddings;
pub mod factory; pub mod factory;
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
@@ -26,6 +27,7 @@ pub mod session_store;
pub mod tag_repository; pub mod tag_repository;
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
pub mod user_repository; pub mod user_repository;
#[cfg(feature = "smart-features")]
pub mod vector; pub mod vector;
// Re-export for convenience // Re-export for convenience

View File

@@ -3,11 +3,17 @@ name = "notes-worker"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[features]
default = ["sqlite", "smart-features"]
sqlite = ["notes-infra/sqlite", "sqlx/sqlite"]
# postgres = ["notes-infra/postgres", "sqlx/postgres"]
smart-features = ["notes-infra/smart-features"]
[dependencies] [dependencies]
anyhow = "1.0.100" anyhow = "1.0.100"
async-nats = "0.45.0" async-nats = "0.45.0"
notes-domain = { path = "../notes-domain" } notes-domain = { path = "../notes-domain" }
notes-infra = { path = "../notes-infra" } notes-infra = { path = "../notes-infra", default-features = false }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.146" serde_json = "1.0.146"
tokio = { version = "1.48.0", features = ["full"] } tokio = { version = "1.48.0", features = ["full"] }

View File

@@ -1,10 +1,13 @@
#[cfg(feature = "smart-features")]
use notes_infra::factory::{EmbeddingProvider, VectorProvider}; use notes_infra::factory::{EmbeddingProvider, VectorProvider};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
pub broker_url: String, pub broker_url: String,
pub database_url: String, pub database_url: String,
#[cfg(feature = "smart-features")]
pub embedding_provider: EmbeddingProvider, pub embedding_provider: EmbeddingProvider,
#[cfg(feature = "smart-features")]
pub vector_provider: VectorProvider, pub vector_provider: VectorProvider,
} }
@@ -13,7 +16,9 @@ impl Default for Config {
Self { Self {
broker_url: "nats://localhost:4222".to_string(), broker_url: "nats://localhost:4222".to_string(),
database_url: "sqlite::memory:".to_string(), database_url: "sqlite::memory:".to_string(),
#[cfg(feature = "smart-features")]
embedding_provider: EmbeddingProvider::FastEmbed, embedding_provider: EmbeddingProvider::FastEmbed,
#[cfg(feature = "smart-features")]
vector_provider: VectorProvider::Qdrant { vector_provider: VectorProvider::Qdrant {
url: "http://localhost:6334".to_string(), url: "http://localhost:6334".to_string(),
collection: "notes".to_string(), collection: "notes".to_string(),
@@ -26,6 +31,7 @@ impl Config {
pub fn from_env() -> Self { pub fn from_env() -> Self {
let _ = dotenvy::dotenv(); let _ = dotenvy::dotenv();
#[cfg(feature = "smart-features")]
let embedding_provider = match std::env::var("EMBEDDING_PROVIDER") let embedding_provider = match std::env::var("EMBEDDING_PROVIDER")
.unwrap_or_default() .unwrap_or_default()
.as_str() .as_str()
@@ -33,6 +39,7 @@ impl Config {
_ => EmbeddingProvider::FastEmbed, _ => EmbeddingProvider::FastEmbed,
}; };
#[cfg(feature = "smart-features")]
let vector_provider = match std::env::var("VECTOR_PROVIDER") let vector_provider = match std::env::var("VECTOR_PROVIDER")
.unwrap_or_default() .unwrap_or_default()
.as_str() .as_str()
@@ -48,7 +55,9 @@ impl Config {
Self { Self {
broker_url: std::env::var("BROKER_URL").unwrap_or("nats://localhost:4222".to_string()), broker_url: std::env::var("BROKER_URL").unwrap_or("nats://localhost:4222".to_string()),
database_url: std::env::var("DATABASE_URL").unwrap_or("sqlite::memory:".to_string()), database_url: std::env::var("DATABASE_URL").unwrap_or("sqlite::memory:".to_string()),
#[cfg(feature = "smart-features")]
embedding_provider, embedding_provider,
#[cfg(feature = "smart-features")]
vector_provider, vector_provider,
} }
} }

View File

@@ -1,5 +1,7 @@
use futures_util::StreamExt; use futures_util::StreamExt;
#[cfg(feature = "smart-features")]
use notes_domain::services::SmartNoteService; use notes_domain::services::SmartNoteService;
#[cfg(feature = "smart-features")]
use notes_infra::{ use notes_infra::{
DatabaseConfig, DatabaseConfig,
factory::{ factory::{
@@ -25,42 +27,51 @@ async fn main() -> anyhow::Result<()> {
let config = Config::from_env(); let config = Config::from_env();
let nats_client = async_nats::connect(&config.broker_url).await?; let nats_client = async_nats::connect(&config.broker_url).await?;
let db_config = DatabaseConfig::new(config.database_url.clone());
let db_pool = build_database_pool(&db_config).await?;
// Initialize smart feature adapters #[cfg(feature = "smart-features")]
let embedding_generator = build_embedding_generator(&config.embedding_provider).await?; {
let vector_store = build_vector_store(&config.vector_provider).await?; let db_config = DatabaseConfig::new(config.database_url.clone());
let link_repo = build_link_repository(&db_pool).await?; let db_pool = build_database_pool(&db_config).await?;
// Create the service // Initialize smart feature adapters
let smart_service = SmartNoteService::new(embedding_generator, vector_store, link_repo); let embedding_generator = build_embedding_generator(&config.embedding_provider).await?;
tracing::info!( let vector_store = build_vector_store(&config.vector_provider).await?;
"SmartNoteService initialized successfully with {:?}", let link_repo = build_link_repository(&db_pool).await?;
config.embedding_provider
);
// Subscribe to note update events // Create the service
let mut subscriber = nats_client.subscribe("notes.updated").await?; let smart_service = SmartNoteService::new(embedding_generator, vector_store, link_repo);
tracing::info!("Worker listening on 'notes.updated'..."); tracing::info!(
"SmartNoteService initialized successfully with {:?}",
config.embedding_provider
);
while let Some(msg) = subscriber.next().await { // Subscribe to note update events
// Parse message payload (assuming the payload IS the Note JSON) let mut subscriber = nats_client.subscribe("notes.updated").await?;
let note_result: Result<notes_domain::Note, _> = serde_json::from_slice(&msg.payload); tracing::info!("Worker listening on 'notes.updated'...");
match note_result { while let Some(msg) = subscriber.next().await {
Ok(note) => { // Parse message payload (assuming the payload IS the Note JSON)
tracing::info!("Processing smart features for note: {}", note.id); let note_result: Result<notes_domain::Note, _> = serde_json::from_slice(&msg.payload);
match smart_service.process_note(&note).await {
Ok(_) => tracing::info!("Successfully processed note {}", note.id), match note_result {
Err(e) => tracing::error!("Failed to process note {}: {}", note.id, e), Ok(note) => {
tracing::info!("Processing smart features for note: {}", note.id);
match smart_service.process_note(&note).await {
Ok(_) => tracing::info!("Successfully processed note {}", note.id),
Err(e) => tracing::error!("Failed to process note {}: {}", note.id, e),
}
}
Err(e) => {
tracing::error!("Failed to deserialize note from message: {}", e);
} }
}
Err(e) => {
tracing::error!("Failed to deserialize note from message: {}", e);
} }
} }
} }
#[cfg(not(feature = "smart-features"))]
{
tracing::info!("Smart features are disabled. Worker will exit.");
}
Ok(()) Ok(())
} }