refactor (v2): better arch

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-06-07 21:19:54 +02:00
parent 0753f3d256
commit 839308ec19
166 changed files with 8553 additions and 884 deletions

22
crates/wiring/Cargo.toml Normal file
View 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
View 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
View 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(),
})
}