feat: event infrastructure — payload, transport, NATS adapter

- EventPublisher now takes &DomainEvent (11 call sites + 3 impls updated)
- EventEnvelope + EventConsumer port in domain
- event-payload: serializable DomainEvent mirror with subject routing
- event-transport: generic Transport/MessageSource traits, publisher/consumer adapters
- adapters-nats: JetStream publish + durable pull consumer
This commit is contained in:
2026-05-31 11:50:16 +02:00
parent dacfc3d453
commit 0e9911ebfc
24 changed files with 1294 additions and 21 deletions

View File

@@ -0,0 +1,252 @@
use domain::{errors::DomainError, events::DomainEvent, value_objects::SystemId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum EventPayload {
AssetIngested {
asset_id: String,
owner_user_id: String,
timestamp: String,
},
MetadataUpdated {
asset_id: String,
updated_by: String,
timestamp: String,
},
AssetDeleted {
asset_id: String,
deleted_by: String,
timestamp: String,
},
ShareCreated {
scope_id: String,
shareable_id: String,
created_by: String,
timestamp: String,
},
ShareRevoked {
scope_id: String,
revoked_by: String,
timestamp: String,
},
SidecarSyncRequested {
asset_id: String,
timestamp: String,
},
JobEnqueued {
job_id: String,
job_type: String,
timestamp: String,
},
JobCompleted {
job_id: String,
timestamp: String,
},
JobFailed {
job_id: String,
error: String,
timestamp: String,
},
}
impl EventPayload {
pub fn subject(&self) -> &'static str {
match self {
Self::AssetIngested { .. } => "assets.ingested",
Self::MetadataUpdated { .. } => "metadata.updated",
Self::AssetDeleted { .. } => "assets.deleted",
Self::ShareCreated { .. } => "shares.created",
Self::ShareRevoked { .. } => "shares.revoked",
Self::SidecarSyncRequested { .. } => "sidecars.sync_requested",
Self::JobEnqueued { .. } => "jobs.enqueued",
Self::JobCompleted { .. } => "jobs.completed",
Self::JobFailed { .. } => "jobs.failed",
}
}
}
impl From<&DomainEvent> for EventPayload {
fn from(e: &DomainEvent) -> Self {
match e {
DomainEvent::AssetIngested {
asset_id,
owner_user_id,
timestamp,
} => Self::AssetIngested {
asset_id: asset_id.to_string(),
owner_user_id: owner_user_id.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::MetadataUpdated {
asset_id,
updated_by,
timestamp,
} => Self::MetadataUpdated {
asset_id: asset_id.to_string(),
updated_by: updated_by.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::AssetDeleted {
asset_id,
deleted_by,
timestamp,
} => Self::AssetDeleted {
asset_id: asset_id.to_string(),
deleted_by: deleted_by.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::ShareCreated {
scope_id,
shareable_id,
created_by,
timestamp,
} => Self::ShareCreated {
scope_id: scope_id.to_string(),
shareable_id: shareable_id.to_string(),
created_by: created_by.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::ShareRevoked {
scope_id,
revoked_by,
timestamp,
} => Self::ShareRevoked {
scope_id: scope_id.to_string(),
revoked_by: revoked_by.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::SidecarSyncRequested {
asset_id,
timestamp,
} => Self::SidecarSyncRequested {
asset_id: asset_id.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::JobEnqueued {
job_id,
job_type,
timestamp,
} => Self::JobEnqueued {
job_id: job_id.to_string(),
job_type: job_type.clone(),
timestamp: timestamp.to_string(),
},
DomainEvent::JobCompleted { job_id, timestamp } => Self::JobCompleted {
job_id: job_id.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::JobFailed {
job_id,
error,
timestamp,
} => Self::JobFailed {
job_id: job_id.to_string(),
error: error.clone(),
timestamp: timestamp.to_string(),
},
}
}
}
fn parse_uuid(s: &str, field: &str) -> Result<uuid::Uuid, DomainError> {
uuid::Uuid::parse_str(s)
.map_err(|_| DomainError::Internal(format!("invalid uuid for {field}: {s}")))
}
fn parse_timestamp(s: &str) -> Result<domain::value_objects::DateTimeStamp, DomainError> {
use chrono::DateTime;
let dt = DateTime::parse_from_rfc3339(s)
.map_err(|_| DomainError::Internal(format!("invalid timestamp: {s}")))?;
Ok(domain::value_objects::DateTimeStamp::from_datetime(
dt.with_timezone(&chrono::Utc),
))
}
impl TryFrom<EventPayload> for DomainEvent {
type Error = DomainError;
fn try_from(p: EventPayload) -> Result<Self, DomainError> {
Ok(match p {
EventPayload::AssetIngested {
asset_id,
owner_user_id,
timestamp,
} => DomainEvent::AssetIngested {
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
owner_user_id: SystemId::from_uuid(parse_uuid(&owner_user_id, "owner_user_id")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::MetadataUpdated {
asset_id,
updated_by,
timestamp,
} => DomainEvent::MetadataUpdated {
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
updated_by: SystemId::from_uuid(parse_uuid(&updated_by, "updated_by")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::AssetDeleted {
asset_id,
deleted_by,
timestamp,
} => DomainEvent::AssetDeleted {
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
deleted_by: SystemId::from_uuid(parse_uuid(&deleted_by, "deleted_by")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::ShareCreated {
scope_id,
shareable_id,
created_by,
timestamp,
} => DomainEvent::ShareCreated {
scope_id: SystemId::from_uuid(parse_uuid(&scope_id, "scope_id")?),
shareable_id: SystemId::from_uuid(parse_uuid(&shareable_id, "shareable_id")?),
created_by: SystemId::from_uuid(parse_uuid(&created_by, "created_by")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::ShareRevoked {
scope_id,
revoked_by,
timestamp,
} => DomainEvent::ShareRevoked {
scope_id: SystemId::from_uuid(parse_uuid(&scope_id, "scope_id")?),
revoked_by: SystemId::from_uuid(parse_uuid(&revoked_by, "revoked_by")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::SidecarSyncRequested {
asset_id,
timestamp,
} => DomainEvent::SidecarSyncRequested {
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::JobEnqueued {
job_id,
job_type,
timestamp,
} => DomainEvent::JobEnqueued {
job_id: SystemId::from_uuid(parse_uuid(&job_id, "job_id")?),
job_type,
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::JobCompleted { job_id, timestamp } => DomainEvent::JobCompleted {
job_id: SystemId::from_uuid(parse_uuid(&job_id, "job_id")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::JobFailed {
job_id,
error,
timestamp,
} => DomainEvent::JobFailed {
job_id: SystemId::from_uuid(parse_uuid(&job_id, "job_id")?),
error,
timestamp: parse_timestamp(&timestamp)?,
},
})
}
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,89 @@
use crate::EventPayload;
use domain::{events::DomainEvent, value_objects::SystemId};
fn make_timestamp() -> domain::value_objects::DateTimeStamp {
domain::value_objects::DateTimeStamp::now()
}
#[test]
fn subject_mapping() {
let cases = vec![
(
DomainEvent::AssetIngested {
asset_id: SystemId::new(),
owner_user_id: SystemId::new(),
timestamp: make_timestamp(),
},
"assets.ingested",
),
(
DomainEvent::JobEnqueued {
job_id: SystemId::new(),
job_type: "extract_metadata".into(),
timestamp: make_timestamp(),
},
"jobs.enqueued",
),
(
DomainEvent::JobFailed {
job_id: SystemId::new(),
error: "boom".into(),
timestamp: make_timestamp(),
},
"jobs.failed",
),
];
for (event, expected_subject) in cases {
let payload = EventPayload::from(&event);
assert_eq!(payload.subject(), expected_subject);
}
}
#[test]
fn roundtrip_asset_ingested() {
let id = SystemId::new();
let owner = SystemId::new();
let event = DomainEvent::AssetIngested {
asset_id: id,
owner_user_id: owner,
timestamp: make_timestamp(),
};
let payload = EventPayload::from(&event);
let json = serde_json::to_vec(&payload).unwrap();
let back: EventPayload = serde_json::from_slice(&json).unwrap();
let restored = DomainEvent::try_from(back).unwrap();
if let DomainEvent::AssetIngested {
asset_id,
owner_user_id,
..
} = restored
{
assert_eq!(asset_id, id);
assert_eq!(owner_user_id, owner);
} else {
panic!("wrong variant");
}
}
#[test]
fn roundtrip_job_failed() {
let jid = SystemId::new();
let event = DomainEvent::JobFailed {
job_id: jid,
error: "plugin crashed".into(),
timestamp: make_timestamp(),
};
let payload = EventPayload::from(&event);
let back = DomainEvent::try_from(payload).unwrap();
if let DomainEvent::JobFailed { job_id, error, .. } = back {
assert_eq!(job_id, jid);
assert_eq!(error, "plugin crashed");
} else {
panic!("wrong variant");
}
}