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

View File

@@ -0,0 +1,13 @@
[package]
name = "application"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }
futures = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,9 @@
pub struct RegisterCommand {
pub email: String,
pub password: String,
}
pub struct LoginCommand {
pub email: String,
pub password: String,
}

View File

@@ -0,0 +1,37 @@
use domain::{
errors::{DomainError, DomainResult},
user::{
entity::User,
value_objects::{Email, Password},
},
};
use super::commands::LoginCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> DomainResult<User> {
let email = Email::new(&cmd.email)?;
let password = Password::new(cmd.password)?;
let user = ctx
.repos
.user
.find_by_email(&email)
.await?
.ok_or_else(|| DomainError::NotFound(format!("user {email}")))?;
let hash = user
.password_hash
.as_ref()
.ok_or_else(|| DomainError::Forbidden("account uses external authentication".into()))?;
if !ctx.services.password_hasher.verify(&password, hash).await? {
return Err(DomainError::Forbidden("invalid credentials".into()));
}
Ok(user)
}
#[cfg(test)]
#[path = "tests/login.rs"]
mod tests;

View File

@@ -0,0 +1,3 @@
pub mod commands;
pub mod login;
pub mod register;

View File

@@ -0,0 +1,31 @@
use domain::{
errors::DomainResult,
user::{
entity::User,
value_objects::{Email, Password},
},
};
use super::commands::RegisterCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> DomainResult<User> {
let email = Email::new(&cmd.email)?;
let password = Password::new(cmd.password)?;
if ctx.repos.user.find_by_email(&email).await?.is_some() {
return Err(domain::errors::DomainError::Conflict(format!(
"user with email {} already exists",
email
)));
}
let hash = ctx.services.password_hasher.hash(&password).await?;
let user = User::new_local(email, hash);
ctx.repos.user.save(&user).await?;
Ok(user)
}
#[cfg(test)]
#[path = "tests/register.rs"]
mod tests;

View File

@@ -0,0 +1,66 @@
use crate::{
auth::{
commands::{LoginCommand, RegisterCommand},
login, register,
},
test_helpers::TestContext,
};
async fn registered_ctx() -> (TestContext, String, String) {
let t = TestContext::new();
let email = "user@example.com".to_string();
let password = "password123".to_string();
register::execute(
&t.ctx,
RegisterCommand {
email: email.clone(),
password: password.clone(),
},
)
.await
.unwrap();
(t, email, password)
}
#[tokio::test]
async fn valid_credentials_return_user() {
let (t, email, password) = registered_ctx().await;
let user = login::execute(&t.ctx, LoginCommand { email, password })
.await
.unwrap();
assert_eq!(user.email.as_ref(), "user@example.com");
}
#[tokio::test]
async fn wrong_password_is_rejected() {
let (t, email, _) = registered_ctx().await;
let result = login::execute(
&t.ctx,
LoginCommand {
email,
password: "wrongpass".into(),
},
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::Forbidden(_))
));
}
#[tokio::test]
async fn unknown_email_is_not_found() {
let t = TestContext::new();
let result = login::execute(
&t.ctx,
LoginCommand {
email: "ghost@example.com".into(),
password: "password123".into(),
},
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::NotFound(_))
));
}

View File

@@ -0,0 +1,56 @@
use crate::{
auth::{commands::RegisterCommand, register},
test_helpers::TestContext,
};
#[tokio::test]
async fn registers_new_user() {
let t = TestContext::new();
let user = register::execute(
&t.ctx,
RegisterCommand {
email: "user@example.com".into(),
password: "password123".into(),
},
)
.await
.unwrap();
assert_eq!(user.email.as_ref(), "user@example.com");
assert!(user.password_hash.is_some());
}
#[tokio::test]
async fn rejects_duplicate_email() {
let t = TestContext::new();
let cmd = || RegisterCommand {
email: "dup@example.com".into(),
password: "password123".into(),
};
register::execute(&t.ctx, cmd()).await.unwrap();
let result = register::execute(&t.ctx, cmd()).await;
assert!(matches!(
result,
Err(domain::errors::DomainError::Conflict(_))
));
}
#[tokio::test]
async fn rejects_invalid_email() {
let t = TestContext::new();
let result = register::execute(
&t.ctx,
RegisterCommand {
email: "not-an-email".into(),
password: "password123".into(),
},
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::Validation(_))
));
}

View File

@@ -0,0 +1,33 @@
/// Application-level configuration. Auth and infra adapter config lives in their own crates.
#[derive(Debug, Clone)]
pub struct AppConfig {
pub base_url: String,
pub smart: SmartConfig,
/// When false the `/auth/register` endpoint returns 403.
pub allow_registration: bool,
}
#[derive(Debug, Clone)]
pub struct SmartConfig {
pub neighbour_limit: usize,
pub min_similarity: f32,
}
impl Default for SmartConfig {
fn default() -> Self {
Self {
neighbour_limit: 10,
min_similarity: 0.7,
}
}
}
impl AppConfig {
pub fn from_env() -> Self {
Self {
base_url: std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()),
smart: SmartConfig::default(),
allow_registration: true,
}
}
}

View File

@@ -0,0 +1,37 @@
use std::sync::Arc;
use domain::{
events::{EventConsumer, EventPublisher},
note::ports::{LinkRepository, NoteRepository},
smart::ports::{EmbeddingGenerator, VectorStore},
tag::ports::TagRepository,
user::ports::{PasswordHasher, UserRepository},
};
use crate::config::AppConfig;
#[derive(Clone)]
pub struct Repositories {
pub note: Arc<dyn NoteRepository>,
pub tag: Arc<dyn TagRepository>,
pub user: Arc<dyn UserRepository>,
pub link: Arc<dyn LinkRepository>,
}
#[derive(Clone)]
pub struct Services {
pub password_hasher: Arc<dyn PasswordHasher>,
pub event_publisher: Arc<dyn EventPublisher>,
/// None when smart features are not configured.
pub embedding: Option<Arc<dyn EmbeddingGenerator>>,
/// None when smart features are not configured.
pub vector_store: Option<Arc<dyn VectorStore>>,
pub event_consumer: Arc<dyn EventConsumer>,
}
#[derive(Clone)]
pub struct AppContext {
pub repos: Repositories,
pub services: Services,
pub config: AppConfig,
}

View File

@@ -0,0 +1,10 @@
pub mod auth;
pub mod config;
pub mod context;
pub mod notes;
pub mod smart;
pub mod tags;
pub mod worker;
#[cfg(test)]
pub(crate) mod test_helpers;

View File

@@ -0,0 +1,51 @@
use domain::{
errors::{DomainError, DomainResult},
note::entity::{MAX_TAGS_PER_NOTE, NoteId},
tag::entity::TagId,
user::entity::UserId,
};
use super::commands::AddTagCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: AddTagCommand) -> DomainResult<()> {
let note_id = NoteId::from_uuid(cmd.note_id);
let tag_id = TagId::from_uuid(cmd.tag_id);
let user_id = UserId::from_uuid(cmd.user_id);
let note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot modify another user's note".into(),
));
}
if !note.can_add_tag() {
return Err(DomainError::Conflict(format!(
"note already has the maximum of {MAX_TAGS_PER_NOTE} tags"
)));
}
let tag = ctx
.repos
.tag
.find_by_id(&tag_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("tag {}", cmd.tag_id)))?;
if tag.user_id != user_id {
return Err(DomainError::Forbidden("tag belongs to another user".into()));
}
ctx.repos.tag.add_to_note(&tag_id, &note_id).await
}
#[cfg(test)]
#[path = "tests/add_tag.rs"]
mod tests;

View File

@@ -0,0 +1,30 @@
use domain::{
errors::{DomainError, DomainResult},
note::entity::Note,
user::entity::UserId,
};
use super::commands::ArchiveNoteCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: ArchiveNoteCommand) -> DomainResult<Note> {
let note_id = domain::note::entity::NoteId::from_uuid(cmd.note_id);
let user_id = UserId::from_uuid(cmd.user_id);
let mut note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot modify another user's note".into(),
));
}
note.set_archived(cmd.archived);
ctx.repos.note.save(&note).await?;
Ok(note)
}

View File

@@ -0,0 +1,46 @@
use uuid::Uuid;
pub struct CreateNoteCommand {
pub user_id: Uuid,
pub title: Option<String>,
pub content: String,
pub color: Option<String>,
pub is_pinned: bool,
}
pub struct UpdateNoteCommand {
pub note_id: Uuid,
pub user_id: Uuid,
pub title: Option<String>,
pub content: Option<String>,
pub color: Option<String>,
}
pub struct DeleteNoteCommand {
pub note_id: Uuid,
pub user_id: Uuid,
}
pub struct PinNoteCommand {
pub note_id: Uuid,
pub user_id: Uuid,
pub pinned: bool,
}
pub struct ArchiveNoteCommand {
pub note_id: Uuid,
pub user_id: Uuid,
pub archived: bool,
}
pub struct AddTagCommand {
pub note_id: Uuid,
pub tag_id: Uuid,
pub user_id: Uuid,
}
pub struct RemoveTagCommand {
pub note_id: Uuid,
pub tag_id: Uuid,
pub user_id: Uuid,
}

View File

@@ -0,0 +1,46 @@
use domain::{
errors::DomainResult,
events::DomainEvent,
note::{
entity::Note,
value_objects::{NoteColor, NoteTitle},
},
user::entity::UserId,
};
use super::commands::CreateNoteCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: CreateNoteCommand) -> DomainResult<Note> {
let user_id = UserId::from_uuid(cmd.user_id);
let title = NoteTitle::from_optional(cmd.title)?;
let mut note = Note::new(user_id, title, cmd.content);
if let Some(color) = cmd.color {
note.set_color(NoteColor::new(color));
}
if cmd.is_pinned {
note.set_pinned(true);
}
ctx.repos.note.save(&note).await?;
if let Err(e) = ctx
.services
.event_publisher
.publish(&DomainEvent::NoteCreated {
note_id: note.id,
user_id,
})
.await
{
tracing::warn!("failed to publish NoteCreated: {e}");
}
Ok(note)
}
#[cfg(test)]
#[path = "tests/create_note.rs"]
mod tests;

View File

@@ -0,0 +1,44 @@
use domain::{
errors::{DomainError, DomainResult},
events::DomainEvent,
note::entity::NoteId,
user::entity::UserId,
};
use super::commands::DeleteNoteCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: DeleteNoteCommand) -> DomainResult<()> {
let note_id = NoteId::from_uuid(cmd.note_id);
let user_id = UserId::from_uuid(cmd.user_id);
let note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot delete another user's note".into(),
));
}
ctx.repos.note.delete(&note_id).await?;
if let Err(e) = ctx
.services
.event_publisher
.publish(&DomainEvent::NoteDeleted { note_id, user_id })
.await
{
tracing::warn!("failed to publish NoteDeleted: {e}");
}
Ok(())
}
#[cfg(test)]
#[path = "tests/delete_note.rs"]
mod tests;

View File

@@ -0,0 +1,33 @@
use domain::{errors::DomainResult, note::entity::NoteFilter, user::entity::UserId};
use crate::context::AppContext;
pub struct ExportedNote {
pub title: Option<String>,
pub content: String,
pub color: String,
pub is_pinned: bool,
pub is_archived: bool,
pub tags: Vec<String>,
}
pub async fn execute(ctx: &AppContext, user_id: uuid::Uuid) -> DomainResult<Vec<ExportedNote>> {
let uid = UserId::from_uuid(user_id);
let notes = ctx
.repos
.note
.find_by_user(&uid, NoteFilter::default())
.await?;
Ok(notes
.into_iter()
.map(|n| ExportedNote {
title: n.title.map(|t| t.into_inner()),
content: n.content,
color: n.color.into_inner(),
is_pinned: n.is_pinned,
is_archived: n.is_archived,
tags: n.tags.into_iter().map(|t| t.name.into_inner()).collect(),
})
.collect())
}

View File

@@ -0,0 +1,28 @@
use domain::{
errors::{DomainError, DomainResult},
note::entity::Note,
user::entity::UserId,
};
use super::queries::GetNoteQuery;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, q: GetNoteQuery) -> DomainResult<Note> {
let note_id = domain::note::entity::NoteId::from_uuid(q.note_id);
let user_id = UserId::from_uuid(q.user_id);
let note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", q.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"note belongs to another user".into(),
));
}
Ok(note)
}

View File

@@ -0,0 +1,28 @@
use domain::{
errors::{DomainError, DomainResult},
note::entity::{NoteId, NoteLink},
user::entity::UserId,
};
use super::queries::GetRelatedQuery;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, q: GetRelatedQuery) -> DomainResult<Vec<NoteLink>> {
let note_id = NoteId::from_uuid(q.note_id);
let user_id = UserId::from_uuid(q.user_id);
let note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", q.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"note belongs to another user".into(),
));
}
ctx.repos.link.find_for_note(&note_id).await
}

View File

@@ -0,0 +1,28 @@
use domain::{
errors::{DomainError, DomainResult},
note::entity::{NoteId, NoteVersion},
user::entity::UserId,
};
use super::queries::GetVersionsQuery;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, q: GetVersionsQuery) -> DomainResult<Vec<NoteVersion>> {
let note_id = NoteId::from_uuid(q.note_id);
let user_id = UserId::from_uuid(q.user_id);
let note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", q.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"note belongs to another user".into(),
));
}
ctx.repos.note.find_versions(&note_id).await
}

View File

@@ -0,0 +1,73 @@
use domain::errors::DomainResult;
use super::{
add_tag, archive_note,
commands::{AddTagCommand, ArchiveNoteCommand, CreateNoteCommand},
create_note,
};
use crate::context::AppContext;
use crate::tags::{commands::CreateTagCommand, create_tag};
pub struct ImportNote {
pub title: Option<String>,
pub content: String,
pub color: Option<String>,
pub is_pinned: bool,
pub is_archived: bool,
pub tags: Vec<String>,
}
pub async fn execute(
ctx: &AppContext,
user_id: uuid::Uuid,
notes: Vec<ImportNote>,
) -> DomainResult<()> {
for item in notes {
let note = create_note::execute(
ctx,
CreateNoteCommand {
user_id,
title: item.title,
content: item.content,
color: item.color,
is_pinned: item.is_pinned,
},
)
.await?;
if item.is_archived {
archive_note::execute(
ctx,
ArchiveNoteCommand {
note_id: note.id.as_uuid(),
user_id,
archived: true,
},
)
.await?;
}
for tag_name in item.tags {
let tag = create_tag::execute(
ctx,
CreateTagCommand {
user_id,
name: tag_name,
},
)
.await?;
add_tag::execute(
ctx,
AddTagCommand {
note_id: note.id.as_uuid(),
tag_id: tag.id.as_uuid(),
user_id,
},
)
.await?;
}
}
Ok(())
}

View File

@@ -0,0 +1,22 @@
use domain::{
errors::DomainResult, note::entity::Note, tag::value_objects::TagName, user::entity::UserId,
};
use super::queries::ListNotesQuery;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, q: ListNotesQuery) -> DomainResult<Vec<Note>> {
let user_id = UserId::from_uuid(q.user_id);
let mut filter = q.filter;
if let Some(name_str) = q.tag_name {
let name = TagName::new(name_str)?;
match ctx.repos.tag.find_by_name(&user_id, &name).await? {
Some(tag) => filter.tag_id = Some(tag.id),
// Tag doesn't exist for this user — no notes can match.
None => return Ok(vec![]),
}
}
ctx.repos.note.find_by_user(&user_id, filter).await
}

View File

@@ -0,0 +1,16 @@
pub mod add_tag;
pub mod archive_note;
pub mod commands;
pub mod create_note;
pub mod delete_note;
pub mod export_notes;
pub mod get_note;
pub mod get_related;
pub mod get_versions;
pub mod import_notes;
pub mod list_notes;
pub mod pin_note;
pub mod queries;
pub mod remove_tag;
pub mod search_notes;
pub mod update_note;

View File

@@ -0,0 +1,30 @@
use domain::{
errors::{DomainError, DomainResult},
note::entity::Note,
user::entity::UserId,
};
use super::commands::PinNoteCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: PinNoteCommand) -> DomainResult<Note> {
let note_id = domain::note::entity::NoteId::from_uuid(cmd.note_id);
let user_id = UserId::from_uuid(cmd.user_id);
let mut note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot modify another user's note".into(),
));
}
note.set_pinned(cmd.pinned);
ctx.repos.note.save(&note).await?;
Ok(note)
}

View File

@@ -0,0 +1,33 @@
use uuid::Uuid;
use domain::note::entity::NoteFilter;
/// Query to list a user's notes.
/// Provide either `filter.tag_id` (already resolved) **or** `tag_name`
/// (the use case will resolve it). `tag_name` takes precedence.
pub struct ListNotesQuery {
pub user_id: Uuid,
pub filter: NoteFilter,
/// If set, resolves the tag by name before applying the filter.
pub tag_name: Option<String>,
}
pub struct GetNoteQuery {
pub note_id: Uuid,
pub user_id: Uuid,
}
pub struct SearchNotesQuery {
pub user_id: Uuid,
pub query: String,
}
pub struct GetVersionsQuery {
pub note_id: Uuid,
pub user_id: Uuid,
}
pub struct GetRelatedQuery {
pub note_id: Uuid,
pub user_id: Uuid,
}

View File

@@ -0,0 +1,30 @@
use domain::{
errors::{DomainError, DomainResult},
note::entity::NoteId,
tag::entity::TagId,
user::entity::UserId,
};
use super::commands::RemoveTagCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: RemoveTagCommand) -> DomainResult<()> {
let note_id = NoteId::from_uuid(cmd.note_id);
let tag_id = TagId::from_uuid(cmd.tag_id);
let user_id = UserId::from_uuid(cmd.user_id);
let note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot modify another user's note".into(),
));
}
ctx.repos.tag.remove_from_note(&tag_id, &note_id).await
}

View File

@@ -0,0 +1,9 @@
use domain::{errors::DomainResult, note::entity::Note, user::entity::UserId};
use super::queries::SearchNotesQuery;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, q: SearchNotesQuery) -> DomainResult<Vec<Note>> {
let user_id = UserId::from_uuid(q.user_id);
ctx.repos.note.search(&user_id, &q.query).await
}

View File

@@ -0,0 +1,98 @@
use crate::{
notes::{
add_tag,
commands::{AddTagCommand, CreateNoteCommand},
create_note,
},
tags::{commands::CreateTagCommand, create_tag},
test_helpers::TestContext,
};
use domain::note::entity::MAX_TAGS_PER_NOTE;
use uuid::Uuid;
#[tokio::test]
async fn adds_tag_to_note() {
let t = TestContext::new();
let user_id = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id,
title: None,
content: "tagged".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
let tag = create_tag::execute(
&t.ctx,
CreateTagCommand {
user_id,
name: "rust".into(),
},
)
.await
.unwrap();
add_tag::execute(
&t.ctx,
AddTagCommand {
note_id: note.id.as_uuid(),
tag_id: tag.id.as_uuid(),
user_id,
},
)
.await
.unwrap();
}
#[tokio::test]
async fn rejects_when_tag_limit_reached() {
let t = TestContext::new();
let user_id = Uuid::new_v4();
let mut note = domain::note::entity::Note::new(
domain::user::entity::UserId::from_uuid(user_id),
None,
"content",
);
// fill tags to the limit
for i in 0..MAX_TAGS_PER_NOTE {
let tag = domain::tag::entity::Tag::new(
domain::tag::value_objects::TagName::new(format!("tag-{i}")).unwrap(),
domain::user::entity::UserId::from_uuid(user_id),
);
t.ctx.repos.tag.save(&tag).await.unwrap();
note.tags.push(tag);
}
t.ctx.repos.note.save(&note).await.unwrap();
let extra_tag = create_tag::execute(
&t.ctx,
CreateTagCommand {
user_id,
name: "extra".into(),
},
)
.await
.unwrap();
let result = add_tag::execute(
&t.ctx,
AddTagCommand {
note_id: note.id.as_uuid(),
tag_id: extra_tag.id.as_uuid(),
user_id,
},
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::Conflict(_))
));
}

View File

@@ -0,0 +1,50 @@
use crate::{
notes::{commands::CreateNoteCommand, create_note},
test_helpers::TestContext,
};
use domain::events::DomainEvent;
use uuid::Uuid;
#[tokio::test]
async fn creates_note_and_publishes_event() {
let t = TestContext::new();
let user_id = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id,
title: Some("Hello".into()),
content: "world".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
assert_eq!(note.content, "world");
assert_eq!(note.title.as_ref().unwrap().as_ref(), "Hello");
let events = t.publisher.events.lock().unwrap();
assert!(matches!(events[0], DomainEvent::NoteCreated { .. }));
}
#[tokio::test]
async fn creates_note_without_title() {
let t = TestContext::new();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id: Uuid::new_v4(),
title: None,
content: "untitled".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
assert!(note.title.is_none());
}

View File

@@ -0,0 +1,73 @@
use crate::{
notes::{
commands::{CreateNoteCommand, DeleteNoteCommand},
create_note, delete_note,
},
test_helpers::TestContext,
};
use uuid::Uuid;
#[tokio::test]
async fn owner_can_delete_note() {
let t = TestContext::new();
let user_id = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id,
title: None,
content: "bye".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
delete_note::execute(
&t.ctx,
DeleteNoteCommand {
note_id: note.id.as_uuid(),
user_id,
},
)
.await
.unwrap();
let found = t.ctx.repos.note.find_by_id(&note.id).await.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn other_user_cannot_delete_note() {
let t = TestContext::new();
let owner = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id: owner,
title: None,
content: "mine".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
let result = delete_note::execute(
&t.ctx,
DeleteNoteCommand {
note_id: note.id.as_uuid(),
user_id: Uuid::new_v4(),
},
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::Forbidden(_))
));
}

View File

@@ -0,0 +1,115 @@
use crate::{
notes::{
commands::{CreateNoteCommand, UpdateNoteCommand},
create_note, update_note,
},
test_helpers::TestContext,
};
use uuid::Uuid;
#[tokio::test]
async fn owner_can_update_note() {
let t = TestContext::new();
let user_id = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id,
title: None,
content: "original".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
let updated = update_note::execute(
&t.ctx,
UpdateNoteCommand {
note_id: note.id.as_uuid(),
user_id,
title: None,
content: Some("updated".into()),
color: None,
},
)
.await
.unwrap();
assert_eq!(updated.content, "updated");
}
#[tokio::test]
async fn other_user_cannot_update_note() {
let t = TestContext::new();
let owner = Uuid::new_v4();
let other = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id: owner,
title: None,
content: "secret".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
let result = update_note::execute(
&t.ctx,
UpdateNoteCommand {
note_id: note.id.as_uuid(),
user_id: other,
title: None,
content: Some("hacked".into()),
color: None,
},
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::Forbidden(_))
));
}
#[tokio::test]
async fn update_creates_version_snapshot() {
let t = TestContext::new();
let user_id = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id,
title: None,
content: "v1".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
update_note::execute(
&t.ctx,
UpdateNoteCommand {
note_id: note.id.as_uuid(),
user_id,
title: None,
content: Some("v2".into()),
color: None,
},
)
.await
.unwrap();
let versions = t.ctx.repos.note.find_versions(&note.id).await.unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].content, "v1");
}

View File

@@ -0,0 +1,63 @@
use domain::{
errors::{DomainError, DomainResult},
events::DomainEvent,
note::{
entity::{Note, NoteVersion},
value_objects::{NoteColor, NoteTitle},
},
user::entity::UserId,
};
use super::commands::UpdateNoteCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: UpdateNoteCommand) -> DomainResult<Note> {
let note_id = domain::note::entity::NoteId::from_uuid(cmd.note_id);
let user_id = UserId::from_uuid(cmd.user_id);
let mut note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot modify another user's note".into(),
));
}
let version = NoteVersion::snapshot(&note);
ctx.repos.note.save_version(&version).await?;
if let Some(title) = cmd.title {
note.set_title(NoteTitle::from_optional(Some(title))?);
}
if let Some(content) = cmd.content {
note.set_content(content);
}
if let Some(color) = cmd.color {
note.set_color(NoteColor::new(color));
}
ctx.repos.note.save(&note).await?;
if let Err(e) = ctx
.services
.event_publisher
.publish(&DomainEvent::NoteUpdated {
note_id: note.id,
user_id,
})
.await
{
tracing::warn!("failed to publish NoteUpdated: {e}");
}
Ok(note)
}
#[cfg(test)]
#[path = "tests/update_note.rs"]
mod tests;

View File

@@ -0,0 +1,12 @@
use domain::{errors::DomainResult, note::entity::NoteId};
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, note_id: NoteId) -> DomainResult<()> {
if let Some(vector_store) = ctx.services.vector_store.as_ref()
&& let Err(e) = vector_store.delete(&note_id).await
{
tracing::warn!("failed to delete vector for note {note_id}: {e}");
}
ctx.repos.link.delete_for_source(&note_id).await
}

View File

@@ -0,0 +1,2 @@
pub mod delete_vectors;
pub mod process_note;

View File

@@ -0,0 +1,48 @@
use domain::{
errors::DomainResult,
note::entity::{NoteId, NoteLink},
user::entity::UserId,
};
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, note_id: NoteId, _user_id: UserId) -> DomainResult<()> {
let (Some(embedder), Some(vector_store)) = (
ctx.services.embedding.as_ref(),
ctx.services.vector_store.as_ref(),
) else {
return Ok(());
};
let note = ctx.repos.note.find_by_id(&note_id).await?;
let Some(note) = note else { return Ok(()) };
let text = match &note.title {
Some(t) => format!("{} {}", t.as_ref(), note.content),
None => note.content.clone(),
};
let embedding = embedder.generate(&text).await?;
vector_store.upsert(&note_id, &embedding).await?;
let limit = ctx.config.smart.neighbour_limit;
let similar = vector_store.find_similar(&embedding, limit + 1).await?;
let links: Vec<NoteLink> = similar
.into_iter()
.filter(|(id, score)| *id != note_id && *score >= ctx.config.smart.min_similarity)
.take(limit)
.map(|(target_id, score)| NoteLink::new(note_id, target_id, score))
.collect();
ctx.repos.link.delete_for_source(&note_id).await?;
if !links.is_empty() {
ctx.repos.link.save_links(&links).await?;
}
Ok(())
}
#[cfg(test)]
#[path = "tests/process_note.rs"]
mod tests;

View File

@@ -0,0 +1,113 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::{
errors::DomainResult,
note::entity::{Note, NoteId},
smart::ports::{EmbeddingGenerator, VectorStore},
user::entity::UserId,
};
use uuid::Uuid;
use crate::{
notes::{commands::CreateNoteCommand, create_note},
smart::process_note,
test_helpers::TestContext,
};
struct FakeEmbedder;
#[async_trait]
impl EmbeddingGenerator for FakeEmbedder {
async fn generate(&self, _text: &str) -> DomainResult<Vec<f32>> {
Ok(vec![1.0, 0.0, 0.0])
}
}
#[derive(Default)]
struct FakeVectorStore {
upserted: Mutex<Vec<NoteId>>,
}
#[async_trait]
impl VectorStore for FakeVectorStore {
async fn upsert(&self, id: &NoteId, _vector: &[f32]) -> DomainResult<()> {
self.upserted.lock().unwrap().push(*id);
Ok(())
}
async fn find_similar(
&self,
_vector: &[f32],
_limit: usize,
) -> DomainResult<Vec<(NoteId, f32)>> {
Ok(vec![])
}
async fn delete(&self, _id: &NoteId) -> DomainResult<()> {
Ok(())
}
}
fn ctx_with_smart() -> TestContext {
let mut t = TestContext::new();
let store = Arc::new(FakeVectorStore::default());
t.ctx.services.embedding = Some(Arc::new(FakeEmbedder));
t.ctx.services.vector_store = Some(Arc::clone(&store) as Arc<dyn VectorStore>);
t
}
#[tokio::test]
async fn processes_note_when_smart_enabled() {
let t = ctx_with_smart();
let user_id = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id,
title: None,
content: "interesting content".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
process_note::execute(
&t.ctx,
note.id,
domain::user::entity::UserId::from_uuid(user_id),
)
.await
.unwrap();
}
#[tokio::test]
async fn skips_when_smart_disabled() {
let t = TestContext::new(); // no embedding/vector_store
let user_id = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id,
title: None,
content: "content".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
let result = process_note::execute(
&t.ctx,
note.id,
domain::user::entity::UserId::from_uuid(user_id),
)
.await;
assert!(result.is_ok());
}

View File

@@ -0,0 +1,17 @@
use uuid::Uuid;
pub struct CreateTagCommand {
pub user_id: Uuid,
pub name: String,
}
pub struct DeleteTagCommand {
pub tag_id: Uuid,
pub user_id: Uuid,
}
pub struct RenameTagCommand {
pub tag_id: Uuid,
pub user_id: Uuid,
pub new_name: String,
}

View File

@@ -0,0 +1,26 @@
use domain::{
errors::DomainResult,
tag::{entity::Tag, value_objects::TagName},
user::entity::UserId,
};
use super::commands::CreateTagCommand;
use crate::context::AppContext;
/// Returns an existing tag with the same name if one exists, otherwise creates a new one.
pub async fn execute(ctx: &AppContext, cmd: CreateTagCommand) -> DomainResult<Tag> {
let user_id = UserId::from_uuid(cmd.user_id);
let name = TagName::new(cmd.name)?;
if let Some(existing) = ctx.repos.tag.find_by_name(&user_id, &name).await? {
return Ok(existing);
}
let tag = Tag::new(name, user_id);
ctx.repos.tag.save(&tag).await?;
Ok(tag)
}
#[cfg(test)]
#[path = "tests/create_tag.rs"]
mod tests;

View File

@@ -0,0 +1,28 @@
use domain::{
errors::{DomainError, DomainResult},
tag::entity::TagId,
user::entity::UserId,
};
use super::commands::DeleteTagCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: DeleteTagCommand) -> DomainResult<()> {
let tag_id = TagId::from_uuid(cmd.tag_id);
let user_id = UserId::from_uuid(cmd.user_id);
let tag = ctx
.repos
.tag
.find_by_id(&tag_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("tag {}", cmd.tag_id)))?;
if tag.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot delete another user's tag".into(),
));
}
ctx.repos.tag.delete(&tag_id).await
}

View File

@@ -0,0 +1,9 @@
use domain::{errors::DomainResult, tag::entity::Tag, user::entity::UserId};
use super::queries::ListTagsQuery;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, q: ListTagsQuery) -> DomainResult<Vec<Tag>> {
let user_id = UserId::from_uuid(q.user_id);
ctx.repos.tag.find_by_user(&user_id).await
}

View File

@@ -0,0 +1,6 @@
pub mod commands;
pub mod create_tag;
pub mod delete_tag;
pub mod list_tags;
pub mod queries;
pub mod rename_tag;

View File

@@ -0,0 +1,5 @@
use uuid::Uuid;
pub struct ListTagsQuery {
pub user_id: Uuid,
}

View File

@@ -0,0 +1,31 @@
use domain::{
errors::{DomainError, DomainResult},
tag::{entity::Tag, entity::TagId, value_objects::TagName},
user::entity::UserId,
};
use super::commands::RenameTagCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: RenameTagCommand) -> DomainResult<Tag> {
let tag_id = TagId::from_uuid(cmd.tag_id);
let user_id = UserId::from_uuid(cmd.user_id);
let new_name = TagName::new(cmd.new_name)?;
let mut tag = ctx
.repos
.tag
.find_by_id(&tag_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("tag {}", cmd.tag_id)))?;
if tag.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot rename another user's tag".into(),
));
}
tag.name = new_name;
ctx.repos.tag.save(&tag).await?;
Ok(tag)
}

View File

@@ -0,0 +1,76 @@
use crate::{
tags::{commands::CreateTagCommand, create_tag},
test_helpers::TestContext,
};
use uuid::Uuid;
#[tokio::test]
async fn creates_new_tag() {
let t = TestContext::new();
let tag = create_tag::execute(
&t.ctx,
CreateTagCommand {
user_id: Uuid::new_v4(),
name: "work".into(),
},
)
.await
.unwrap();
assert_eq!(tag.name.as_ref(), "work");
}
#[tokio::test]
async fn returns_existing_tag_with_same_name() {
let t = TestContext::new();
let user_id = Uuid::new_v4();
let first = create_tag::execute(
&t.ctx,
CreateTagCommand {
user_id,
name: "rust".into(),
},
)
.await
.unwrap();
let second = create_tag::execute(
&t.ctx,
CreateTagCommand {
user_id,
name: "rust".into(),
},
)
.await
.unwrap();
assert_eq!(first.id, second.id);
}
#[tokio::test]
async fn different_users_can_have_same_tag_name() {
let t = TestContext::new();
let a = create_tag::execute(
&t.ctx,
CreateTagCommand {
user_id: Uuid::new_v4(),
name: "shared".into(),
},
)
.await
.unwrap();
let b = create_tag::execute(
&t.ctx,
CreateTagCommand {
user_id: Uuid::new_v4(),
name: "shared".into(),
},
)
.await
.unwrap();
assert_ne!(a.id, b.id);
}

View File

@@ -0,0 +1,330 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use async_trait::async_trait;
use domain::{
errors::{DomainError, DomainResult},
events::{DomainEvent, EventPublisher},
note::{
entity::{Note, NoteFilter, NoteId, NoteLink, NoteVersion},
ports::{LinkRepository, NoteRepository},
},
tag::{
entity::{Tag, TagId},
ports::TagRepository,
value_objects::TagName,
},
user::{
entity::{User, UserId},
ports::{PasswordHasher, UserRepository},
value_objects::{Email, Password, PasswordHash},
},
};
use crate::{
config::{AppConfig, SmartConfig},
context::{AppContext, Repositories, Services},
};
// ── Note ─────────────────────────────────────────────────────────────────────
#[derive(Default)]
pub struct MemoryNoteRepo {
notes: Mutex<HashMap<NoteId, Note>>,
versions: Mutex<HashMap<NoteId, Vec<NoteVersion>>>,
}
#[async_trait]
impl NoteRepository for MemoryNoteRepo {
async fn find_by_id(&self, id: &NoteId) -> DomainResult<Option<Note>> {
Ok(self.notes.lock().unwrap().get(id).cloned())
}
async fn find_by_user(&self, user_id: &UserId, filter: NoteFilter) -> DomainResult<Vec<Note>> {
Ok(self
.notes
.lock()
.unwrap()
.values()
.filter(|n| {
n.user_id == *user_id
&& filter.is_pinned.map_or(true, |v| n.is_pinned == v)
&& filter.is_archived.map_or(true, |v| n.is_archived == v)
})
.cloned()
.collect())
}
async fn search(&self, user_id: &UserId, query: &str) -> DomainResult<Vec<Note>> {
let q = query.to_lowercase();
Ok(self
.notes
.lock()
.unwrap()
.values()
.filter(|n| {
n.user_id == *user_id
&& (n.content.to_lowercase().contains(&q)
|| n.title
.as_ref()
.map_or(false, |t| t.as_ref().to_lowercase().contains(&q)))
})
.cloned()
.collect())
}
async fn save(&self, note: &Note) -> DomainResult<()> {
self.notes.lock().unwrap().insert(note.id, note.clone());
Ok(())
}
async fn delete(&self, id: &NoteId) -> DomainResult<()> {
self.notes.lock().unwrap().remove(id);
Ok(())
}
async fn save_version(&self, v: &NoteVersion) -> DomainResult<()> {
self.versions
.lock()
.unwrap()
.entry(v.note_id)
.or_default()
.push(v.clone());
Ok(())
}
async fn find_versions(&self, note_id: &NoteId) -> DomainResult<Vec<NoteVersion>> {
Ok(self
.versions
.lock()
.unwrap()
.get(note_id)
.cloned()
.unwrap_or_default())
}
}
// ── Tag ──────────────────────────────────────────────────────────────────────
#[derive(Default)]
pub struct MemoryTagRepo {
tags: Mutex<HashMap<TagId, Tag>>,
note_tags: Mutex<HashMap<(NoteId, TagId), ()>>,
}
#[async_trait]
impl TagRepository for MemoryTagRepo {
async fn find_by_id(&self, id: &TagId) -> DomainResult<Option<Tag>> {
Ok(self.tags.lock().unwrap().get(id).cloned())
}
async fn find_by_user(&self, user_id: &UserId) -> DomainResult<Vec<Tag>> {
Ok(self
.tags
.lock()
.unwrap()
.values()
.filter(|t| t.user_id == *user_id)
.cloned()
.collect())
}
async fn find_by_name(&self, user_id: &UserId, name: &TagName) -> DomainResult<Option<Tag>> {
Ok(self
.tags
.lock()
.unwrap()
.values()
.find(|t| t.user_id == *user_id && t.name == *name)
.cloned())
}
async fn find_by_note(&self, note_id: &NoteId) -> DomainResult<Vec<Tag>> {
let note_tags = self.note_tags.lock().unwrap();
let tags = self.tags.lock().unwrap();
Ok(note_tags
.keys()
.filter(|(nid, _)| nid == note_id)
.filter_map(|(_, tid)| tags.get(tid).cloned())
.collect())
}
async fn save(&self, tag: &Tag) -> DomainResult<()> {
self.tags.lock().unwrap().insert(tag.id, tag.clone());
Ok(())
}
async fn delete(&self, id: &TagId) -> DomainResult<()> {
self.tags.lock().unwrap().remove(id);
Ok(())
}
async fn add_to_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()> {
self.note_tags
.lock()
.unwrap()
.insert((*note_id, *tag_id), ());
Ok(())
}
async fn remove_from_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()> {
self.note_tags.lock().unwrap().remove(&(*note_id, *tag_id));
Ok(())
}
}
// ── User ─────────────────────────────────────────────────────────────────────
#[derive(Default)]
pub struct MemoryUserRepo {
users: Mutex<HashMap<UserId, User>>,
}
#[async_trait]
impl UserRepository for MemoryUserRepo {
async fn find_by_id(&self, id: &UserId) -> DomainResult<Option<User>> {
Ok(self.users.lock().unwrap().get(id).cloned())
}
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> {
Ok(self
.users
.lock()
.unwrap()
.values()
.find(|u| u.subject == subject)
.cloned())
}
async fn find_by_email(&self, email: &Email) -> DomainResult<Option<User>> {
Ok(self
.users
.lock()
.unwrap()
.values()
.find(|u| u.email.as_ref() == email.as_ref())
.cloned())
}
async fn save(&self, user: &User) -> DomainResult<()> {
self.users.lock().unwrap().insert(user.id, user.clone());
Ok(())
}
async fn delete(&self, id: &UserId) -> DomainResult<()> {
self.users.lock().unwrap().remove(id);
Ok(())
}
}
// ── Link ─────────────────────────────────────────────────────────────────────
#[derive(Default)]
pub struct MemoryLinkRepo {
links: Mutex<Vec<NoteLink>>,
}
#[async_trait]
impl LinkRepository for MemoryLinkRepo {
async fn save_links(&self, links: &[NoteLink]) -> DomainResult<()> {
self.links.lock().unwrap().extend_from_slice(links);
Ok(())
}
async fn delete_for_source(&self, source_id: &NoteId) -> DomainResult<()> {
self.links
.lock()
.unwrap()
.retain(|l| l.source_id != *source_id);
Ok(())
}
async fn find_for_note(&self, note_id: &NoteId) -> DomainResult<Vec<NoteLink>> {
Ok(self
.links
.lock()
.unwrap()
.iter()
.filter(|l| l.source_id == *note_id)
.cloned()
.collect())
}
}
// ── PasswordHasher ───────────────────────────────────────────────────────────
pub struct PlaintextHasher;
#[async_trait]
impl PasswordHasher for PlaintextHasher {
async fn hash(&self, password: &Password) -> DomainResult<PasswordHash> {
Ok(PasswordHash::new(format!("hashed:{}", password.as_ref())))
}
async fn verify(&self, password: &Password, hash: &PasswordHash) -> DomainResult<bool> {
Ok(hash.as_str() == format!("hashed:{}", password.as_ref()))
}
}
// ── EventPublisher ───────────────────────────────────────────────────────────
#[derive(Default)]
pub struct RecordingPublisher {
pub events: Mutex<Vec<DomainEvent>>,
}
#[async_trait]
impl EventPublisher for RecordingPublisher {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
self.events.lock().unwrap().push(event.clone());
Ok(())
}
}
// ── AppContext builder ────────────────────────────────────────────────────────
pub struct TestContext {
pub ctx: AppContext,
pub publisher: Arc<RecordingPublisher>,
}
impl TestContext {
pub fn new() -> Self {
use domain::events::EventConsumer;
use futures::stream::BoxStream;
struct NoopConsumer;
impl EventConsumer for NoopConsumer {
fn consume(&self) -> BoxStream<'_, Result<domain::events::EventEnvelope, DomainError>> {
Box::pin(futures::stream::empty())
}
}
let publisher = Arc::new(RecordingPublisher::default());
let ctx = AppContext {
repos: Repositories {
note: Arc::new(MemoryNoteRepo::default()),
tag: Arc::new(MemoryTagRepo::default()),
user: Arc::new(MemoryUserRepo::default()),
link: Arc::new(MemoryLinkRepo::default()),
},
services: Services {
password_hasher: Arc::new(PlaintextHasher),
event_publisher: Arc::clone(&publisher) as Arc<dyn EventPublisher>,
embedding: None,
vector_store: None,
event_consumer: Arc::new(NoopConsumer),
},
config: AppConfig {
base_url: "http://localhost:3000".into(),
smart: SmartConfig::default(),
allow_registration: true,
},
};
Self { ctx, publisher }
}
}

View File

@@ -0,0 +1,85 @@
use std::sync::Arc;
use domain::events::{EventConsumer, EventEnvelope, EventHandler};
use futures::StreamExt;
use tokio::sync::Semaphore;
const DEFAULT_CONCURRENCY: usize = 8;
pub struct WorkerService {
consumer: Arc<dyn EventConsumer>,
handlers: Vec<Arc<dyn EventHandler>>,
semaphore: Arc<Semaphore>,
}
impl WorkerService {
pub fn new(consumer: Arc<dyn EventConsumer>, handlers: Vec<Arc<dyn EventHandler>>) -> Self {
Self {
consumer,
handlers,
semaphore: Arc::new(Semaphore::new(DEFAULT_CONCURRENCY)),
}
}
pub async fn run(self, mut shutdown: tokio::sync::watch::Receiver<bool>) {
let handlers = Arc::new(self.handlers);
let mut tasks = tokio::task::JoinSet::new();
let mut stream = self.consumer.consume();
loop {
tokio::select! {
biased;
_ = shutdown.changed() => {
tracing::info!("shutdown received, stopping event consumption");
break;
}
item = stream.next() => {
match item {
Some(Ok(envelope)) => {
let permit = self.semaphore.clone().acquire_owned().await;
let Ok(permit) = permit else { break };
let h = Arc::clone(&handlers);
tasks.spawn(async move {
dispatch(h, envelope).await;
drop(permit);
});
}
Some(Err(e)) => tracing::error!("event consumer error: {e}"),
None => break,
}
}
}
}
let in_flight = tasks.len();
if in_flight > 0 {
tracing::info!(in_flight, "draining in-flight tasks");
}
while tasks.join_next().await.is_some() {}
}
}
async fn dispatch(handlers: Arc<Vec<Arc<dyn EventHandler>>>, envelope: EventEnvelope) {
let mut failed = false;
for handler in handlers.iter() {
if let Err(e) = handler.handle(&envelope.event).await {
tracing::warn!("event handler error: {e}");
failed = true;
}
}
let result = if failed {
// At least one handler failed — nack so the transport can redeliver.
// With JetStream this triggers redelivery up to max_deliver times,
// after which the message is considered dead (visible via advisory events).
// Handlers must be idempotent since they may run again on redelivery.
envelope.nack().await
} else {
envelope.ack().await
};
if let Err(e) = result {
tracing::error!("ack/nack failed: {e}");
}
}