refactor (v2): better arch
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
22
crates/wiring/Cargo.toml
Normal file
22
crates/wiring/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "wiring"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# domain + application
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
|
||||
# adapters
|
||||
sqlite = { workspace = true }
|
||||
auth = { workspace = true }
|
||||
event-publisher-memory = { workspace = true }
|
||||
nats = { workspace = true }
|
||||
fastembed-adapter = { workspace = true }
|
||||
qdrant-adapter = { workspace = true }
|
||||
|
||||
# utilities
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
124
crates/wiring/src/config.rs
Normal file
124
crates/wiring/src/config.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use application::config::{AppConfig, SmartConfig};
|
||||
use nats::JetStreamConfig;
|
||||
|
||||
/// Full wiring configuration, sourced from environment variables.
|
||||
///
|
||||
/// Call `WiringConfig::from_env()` at startup. All fields have documented
|
||||
/// env var names and defaults.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WiringConfig {
|
||||
/// `DATABASE_URL` — SQLite file path, e.g. `sqlite://data.db`
|
||||
pub database_url: String,
|
||||
|
||||
/// `NATS_URL` — if set, NATS JetStream is used for events.
|
||||
/// If absent, an in-memory bus is used (suitable for single-process dev).
|
||||
pub nats_url: Option<String>,
|
||||
|
||||
/// `QDRANT_URL` — if set, smart features (embeddings + semantic links) are
|
||||
/// enabled. If absent, `AppContext::services.embedding` and `vector_store`
|
||||
/// are `None`.
|
||||
pub qdrant_url: Option<String>,
|
||||
|
||||
/// `QDRANT_COLLECTION` — collection name. Default: `"notes"`.
|
||||
pub qdrant_collection: String,
|
||||
|
||||
/// `QDRANT_VECTOR_SIZE` — must match the embedding model's output dimension.
|
||||
/// Default: `384` (AllMiniLML6V2).
|
||||
pub qdrant_vector_size: u64,
|
||||
|
||||
/// `BASE_URL` — public base URL, e.g. `http://localhost:3000`.
|
||||
pub base_url: String,
|
||||
|
||||
/// `SMART_NEIGHBOUR_LIMIT` — max similar notes to link per note. Default: `10`.
|
||||
pub smart_neighbour_limit: usize,
|
||||
|
||||
/// `SMART_MIN_SIMILARITY` — cosine similarity threshold for links. Default: `0.7`.
|
||||
pub smart_min_similarity: f32,
|
||||
|
||||
/// `ALLOW_REGISTRATION` — set to `false` to disable the register endpoint.
|
||||
/// Default: `true`.
|
||||
pub allow_registration: bool,
|
||||
|
||||
/// `NATS_STREAM_NAME` — JetStream stream name. Default: `"KNOTES"`.
|
||||
pub nats_stream_name: String,
|
||||
|
||||
/// `NATS_CONSUMER_NAME` — durable consumer name. Default: `"knotes-worker"`.
|
||||
pub nats_consumer_name: String,
|
||||
|
||||
/// `NATS_MAX_DELIVER` — max delivery attempts before a message is dead.
|
||||
/// Default: `5`.
|
||||
pub nats_max_deliver: i64,
|
||||
|
||||
/// `ENABLE_EMBEDDINGS` — load the fastembed model and generate embeddings.
|
||||
/// Should only be `true` in the worker process. The backend only needs
|
||||
/// `VectorStore` (for querying related notes), not `EmbeddingGenerator`.
|
||||
/// Default: `false`.
|
||||
pub enable_embeddings: bool,
|
||||
}
|
||||
|
||||
impl WiringConfig {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
database_url: require_env("DATABASE_URL")?,
|
||||
nats_url: optional_env("NATS_URL"),
|
||||
qdrant_url: optional_env("QDRANT_URL"),
|
||||
qdrant_collection: optional_env("QDRANT_COLLECTION").unwrap_or_else(|| "notes".into()),
|
||||
qdrant_vector_size: parse_env("QDRANT_VECTOR_SIZE", 384)?,
|
||||
base_url: optional_env("BASE_URL").unwrap_or_else(|| "http://localhost:3000".into()),
|
||||
smart_neighbour_limit: parse_env("SMART_NEIGHBOUR_LIMIT", 10)?,
|
||||
smart_min_similarity: parse_env("SMART_MIN_SIMILARITY", 0.7f32)?,
|
||||
nats_stream_name: optional_env("NATS_STREAM_NAME").unwrap_or_else(|| "KNOTES".into()),
|
||||
nats_consumer_name: optional_env("NATS_CONSUMER_NAME")
|
||||
.unwrap_or_else(|| "knotes-worker".into()),
|
||||
nats_max_deliver: parse_env("NATS_MAX_DELIVER", 5i64)?,
|
||||
allow_registration: optional_env("ALLOW_REGISTRATION")
|
||||
.map(|s| s != "false" && s != "0")
|
||||
.unwrap_or(true),
|
||||
enable_embeddings: optional_env("ENABLE_EMBEDDINGS")
|
||||
.map(|s| s == "true" || s == "1")
|
||||
.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn app_config(&self) -> AppConfig {
|
||||
AppConfig {
|
||||
base_url: self.base_url.clone(),
|
||||
smart: SmartConfig {
|
||||
neighbour_limit: self.smart_neighbour_limit,
|
||||
min_similarity: self.smart_min_similarity,
|
||||
},
|
||||
allow_registration: self.allow_registration,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn jetstream_config(&self) -> JetStreamConfig {
|
||||
JetStreamConfig {
|
||||
stream_name: self.nats_stream_name.clone(),
|
||||
consumer_name: self.nats_consumer_name.clone(),
|
||||
max_deliver: self.nats_max_deliver,
|
||||
ack_wait: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn require_env(key: &str) -> anyhow::Result<String> {
|
||||
std::env::var(key).map_err(|_| anyhow::anyhow!("{key} must be set"))
|
||||
}
|
||||
|
||||
fn optional_env(key: &str) -> Option<String> {
|
||||
std::env::var(key).ok().filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn parse_env<T: std::str::FromStr + ToString>(key: &str, default: T) -> anyhow::Result<T>
|
||||
where
|
||||
T::Err: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
match std::env::var(key) {
|
||||
Ok(val) => val
|
||||
.parse::<T>()
|
||||
.map_err(|e| anyhow::anyhow!("invalid {key}={val}: {e}")),
|
||||
Err(_) => Ok(default),
|
||||
}
|
||||
}
|
||||
104
crates/wiring/src/lib.rs
Normal file
104
crates/wiring/src/lib.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
pub mod config;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::context::{AppContext, Repositories, Services};
|
||||
use auth::password::Argon2PasswordHasher;
|
||||
use domain::{
|
||||
events::{EventConsumer, EventPublisher},
|
||||
smart::ports::{EmbeddingGenerator, VectorStore},
|
||||
};
|
||||
|
||||
type OptEmbedding = Option<Arc<dyn EmbeddingGenerator>>;
|
||||
type OptVectorStore = Option<Arc<dyn VectorStore>>;
|
||||
use event_publisher_memory::MemoryEventBus;
|
||||
use fastembed_adapter::{FastEmbedConfig, FastEmbedGenerator};
|
||||
use qdrant_adapter::{QdrantConfig, QdrantVectorStore};
|
||||
use sqlite::{
|
||||
db::{connect, run_migrations},
|
||||
link::SqliteLinkRepository,
|
||||
note::SqliteNoteRepository,
|
||||
tag::SqliteTagRepository,
|
||||
user::SqliteUserRepository,
|
||||
};
|
||||
|
||||
pub use config::WiringConfig;
|
||||
|
||||
/// Assemble a fully wired `AppContext` from the given configuration.
|
||||
///
|
||||
/// Runs database migrations, connects to all configured external services,
|
||||
/// and returns an `AppContext` ready to be handed to `WorkerService` or
|
||||
/// the presentation layer.
|
||||
pub async fn build_context(cfg: &WiringConfig) -> anyhow::Result<AppContext> {
|
||||
// ── Database ──────────────────────────────────────────────────────────────
|
||||
tracing::info!("connecting to database");
|
||||
let pool = connect(&cfg.database_url).await?;
|
||||
run_migrations(&pool).await?;
|
||||
tracing::info!("migrations applied");
|
||||
|
||||
let repos = Repositories {
|
||||
note: Arc::new(SqliteNoteRepository::new(pool.clone())),
|
||||
tag: Arc::new(SqliteTagRepository::new(pool.clone())),
|
||||
user: Arc::new(SqliteUserRepository::new(pool.clone())),
|
||||
link: Arc::new(SqliteLinkRepository::new(pool.clone())),
|
||||
};
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||
let password_hasher = Arc::new(Argon2PasswordHasher);
|
||||
|
||||
// ── Event bus ─────────────────────────────────────────────────────────────
|
||||
let (event_publisher, event_consumer): (Arc<dyn EventPublisher>, Arc<dyn EventConsumer>) =
|
||||
if let Some(ref url) = cfg.nats_url {
|
||||
tracing::info!("connecting to NATS at {url}");
|
||||
let (pub_, con) = nats::setup(url, cfg.jetstream_config())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("nats setup failed: {e}"))?;
|
||||
tracing::info!("NATS JetStream ready");
|
||||
(Arc::new(pub_), Arc::new(con))
|
||||
} else {
|
||||
tracing::info!("no NATS_URL — using in-memory event bus");
|
||||
let bus = MemoryEventBus::new();
|
||||
(bus.publisher(), bus.consumer())
|
||||
};
|
||||
|
||||
// ── Smart features ────────────────────────────────────────────────────────
|
||||
// EmbeddingGenerator: only load the fastembed model in the worker.
|
||||
// The backend only needs VectorStore (for querying related notes).
|
||||
// Loading the model in both processes wastes ~150 MB per process.
|
||||
let embedding: OptEmbedding = if cfg.enable_embeddings && cfg.qdrant_url.is_some() {
|
||||
tracing::info!("loading fastembed embedding model");
|
||||
let embedder = FastEmbedGenerator::new(FastEmbedConfig::default())
|
||||
.map_err(|e| anyhow::anyhow!("fastembed init failed: {e}"))?;
|
||||
Some(Arc::new(embedder) as Arc<dyn EmbeddingGenerator>)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let vector_store: OptVectorStore = if let Some(ref url) = cfg.qdrant_url {
|
||||
tracing::info!("connecting to qdrant at {url}");
|
||||
let qdrant = QdrantVectorStore::new(QdrantConfig {
|
||||
url: url.clone(),
|
||||
collection: cfg.qdrant_collection.clone(),
|
||||
vector_size: cfg.qdrant_vector_size,
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("qdrant client init failed: {e}"))?;
|
||||
qdrant.init(cfg.qdrant_vector_size).await?;
|
||||
tracing::info!(collection = %cfg.qdrant_collection, "qdrant collection ready");
|
||||
Some(Arc::new(qdrant) as Arc<dyn VectorStore>)
|
||||
} else {
|
||||
tracing::info!("no QDRANT_URL — smart features disabled");
|
||||
None
|
||||
};
|
||||
|
||||
Ok(AppContext {
|
||||
repos,
|
||||
services: Services {
|
||||
password_hasher,
|
||||
event_publisher,
|
||||
event_consumer,
|
||||
embedding,
|
||||
vector_store,
|
||||
},
|
||||
config: cfg.app_config(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user