use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
pub use backbone_outbox::OutboxRecord;
use crate::persistence::traits::PersistentEntity;
use crate::service::{ServiceError, ServiceResult};
tokio::task_local! {
static CORRELATION: String;
}
pub async fn with_correlation_id<F: std::future::Future>(id: impl Into<String>, f: F) -> F::Output {
CORRELATION.scope(id.into(), f).await
}
pub fn current_correlation_id() -> Option<String> {
CORRELATION.try_with(|id| id.clone()).ok()
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WriteCtx {
pub correlation_id: String,
pub causation_id: Option<String>,
pub actor: Option<String>,
pub company_id: Option<Uuid>,
pub outbox_schema: Option<String>,
}
impl WriteCtx {
pub fn current(outbox_schema: Option<&str>) -> Self {
let audit = backbone_orm::audit_context::current_request_audit();
let actor = audit
.as_ref()
.map(|a| a.actor.clone())
.filter(|a| !a.is_empty());
let correlation_id = current_correlation_id()
.or_else(|| audit.map(|a| a.correlation_id).filter(|c| !c.is_empty()))
.unwrap_or_else(|| Uuid::new_v4().to_string());
Self {
correlation_id,
causation_id: None,
actor,
company_id: backbone_orm::current_company(),
outbox_schema: outbox_schema.map(str::to_string),
}
}
pub fn company_or_nil(&self) -> Uuid {
self.company_id.unwrap_or_else(Uuid::nil)
}
pub fn company_of(&self, row: &Value) -> Uuid {
row.get("company_id")
.and_then(Value::as_str)
.and_then(|id| Uuid::parse_str(id).ok())
.unwrap_or_else(|| self.company_or_nil())
}
pub async fn stage(&self, mut record: OutboxRecord) -> ServiceResult<()> {
let Some(schema) = self.outbox_schema.as_deref() else {
return Err(ServiceError::Internal(
"an event was staged by hooks that name no outbox schema".into(),
));
};
if record.correlation_id.is_none() {
record.correlation_id = Some(self.correlation_id.clone());
}
if record.causation_id.is_none() {
record.causation_id = self.causation_id.clone();
}
backbone_outbox::outbox::stage_in_write_tx(schema, &record)
.await
.map(|_| ())
.map_err(|e| {
ServiceError::Internal(format!("staging {} failed: {e}", record.event_type))
})
}
}
pub trait DeclaredEvent: serde::Serialize {
const NAME: &'static str;
const VERSION: i32;
const AGGREGATE: &'static str;
fn record(&self, aggregate_id: &str, ctx: &WriteCtx) -> OutboxRecord {
self.record_for(aggregate_id, ctx.company_or_nil(), ctx)
}
fn record_for(&self, aggregate_id: &str, company_id: Uuid, ctx: &WriteCtx) -> OutboxRecord {
let payload = serde_json::to_value(self).unwrap_or(Value::Null);
let mut record = OutboxRecord::new(
Self::NAME,
Self::AGGREGATE,
aggregate_id,
company_id,
payload,
chrono::Utc::now(),
)
.with_version(Self::VERSION)
.with_correlation_id(ctx.correlation_id.clone());
if let Some(cause) = &ctx.causation_id {
record = record.with_causation_id(cause.clone());
}
record
}
}
pub fn payload_from_row<T: serde::de::DeserializeOwned>(
row: &Value,
fields: &[&str],
) -> ServiceResult<T> {
let mut picked = serde_json::Map::new();
if let Value::Object(map) = row {
for field in fields {
if let Some(value) = map.get(*field) {
picked.insert((*field).to_string(), value.clone());
}
}
}
serde_json::from_value(Value::Object(picked)).map_err(|e| {
ServiceError::Internal(format!(
"an event payload could not be read from the row: {e}"
))
})
}
pub async fn migrate_outbox(pool: &backbone_orm::PgPool, schema: &str) -> anyhow::Result<()> {
backbone_outbox::outbox::migrate(pool, schema).await?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeKind {
Created,
Updated,
Transitioned,
Deleted,
Restored,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum Change<'a, E> {
Created {
after: &'a mut E,
},
Updated {
before: &'a E,
after: &'a mut E,
},
Transitioned {
before: &'a E,
after: &'a mut E,
transition: String,
field: String,
from: Option<String>,
to: Option<String>,
},
Deleted {
before: &'a E,
hard: bool,
},
Restored {
entity: &'a E,
},
}
impl<'a, E> Change<'a, E> {
pub fn kind(&self) -> ChangeKind {
match self {
Change::Created { .. } => ChangeKind::Created,
Change::Updated { .. } => ChangeKind::Updated,
Change::Transitioned { .. } => ChangeKind::Transitioned,
Change::Deleted { .. } => ChangeKind::Deleted,
Change::Restored { .. } => ChangeKind::Restored,
}
}
pub fn before(&self) -> Option<&E> {
match self {
Change::Updated { before, .. }
| Change::Transitioned { before, .. }
| Change::Deleted { before, .. } => Some(before),
Change::Created { .. } | Change::Restored { .. } => None,
}
}
pub fn after(&self) -> Option<&E> {
match self {
Change::Created { after }
| Change::Updated { after, .. }
| Change::Transitioned { after, .. } => Some(after),
Change::Restored { entity } => Some(entity),
Change::Deleted { .. } => None,
}
}
pub fn subject(&self) -> &E {
match self {
Change::Deleted { before, .. } => before,
other => other
.after()
.expect("every change but a delete stores a row"),
}
}
}
impl<'a, E> Change<'a, E> {
pub fn after_mut(&mut self) -> Option<&mut E> {
match self {
Change::Created { after }
| Change::Updated { after, .. }
| Change::Transitioned { after, .. } => Some(&mut **after),
Change::Deleted { .. } | Change::Restored { .. } => None,
}
}
pub fn before_and_after_mut(&mut self) -> Option<(&E, &mut E)> {
match self {
Change::Updated { before, after } | Change::Transitioned { before, after, .. } => {
Some((*before, &mut **after))
}
_ => None,
}
}
}
impl<'a, E: serde::Serialize> Change<'a, E> {
pub fn field_changed(&self, name: &str) -> bool {
let after = match self.after().and_then(|a| serde_json::to_value(a).ok()) {
Some(Value::Object(map)) => map,
_ => return false,
};
let now = after.get(name).filter(|v| !v.is_null());
match self.before().and_then(|b| serde_json::to_value(b).ok()) {
Some(Value::Object(before)) => before.get(name).filter(|v| !v.is_null()) != now,
_ => now.is_some(),
}
}
}
impl<'a, E: PersistentEntity> Change<'a, E> {
pub(crate) fn update(before: &'a E, after: &'a mut E) -> Self {
let Some(transition) = crate::write_guard::current_transition() else {
return Change::Updated { before, after };
};
let before_obj = serde_json::to_value(before).ok();
let after_obj = serde_json::to_value(&*after).ok();
let state = |obj: &Option<Value>, field: &str| {
obj.as_ref()
.and_then(|o| o.get(field))
.and_then(Value::as_str)
.map(str::to_string)
};
let moved = E::state_fields()
.iter()
.find(|f| state(&before_obj, f) != state(&after_obj, f))
.or_else(|| E::state_fields().first())
.copied();
match moved {
Some(field) => Change::Transitioned {
from: state(&before_obj, field),
to: state(&after_obj, field),
field: field.to_string(),
transition,
before,
after,
},
None => Change::Updated { before, after },
}
}
}
pub const TRIGGER_MAX_DEPTH: u32 = 8;
pub const TRIGGER_DEPTH_EXCEEDED: &str = "trigger_depth_exceeded";
tokio::task_local! {
static TRIGGER_DEPTH: u32;
}
pub fn current_trigger_depth() -> u32 {
TRIGGER_DEPTH.try_with(|d| *d).unwrap_or(0)
}
pub async fn in_trigger<T, F>(f: F) -> ServiceResult<T>
where
F: std::future::Future<Output = ServiceResult<T>>,
{
let depth = current_trigger_depth();
if depth >= TRIGGER_MAX_DEPTH {
return Err(ServiceError::Violations(vec![crate::violation::Violation::new(
"",
TRIGGER_DEPTH_EXCEEDED,
format!("triggers are nested more than {TRIGGER_MAX_DEPTH} deep: a trigger writes a row whose trigger writes a row, and so on"),
)
.with_param("max", TRIGGER_MAX_DEPTH)]));
}
TRIGGER_DEPTH.scope(depth + 1, f).await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScheduledTrigger {
pub name: &'static str,
pub schedule: &'static str,
pub description: &'static str,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NotificationRequested {
pub channel: String,
pub template: Option<String>,
pub body: Option<String>,
pub to: Value,
pub data: Value,
pub trigger: String,
}
impl NotificationRequested {
pub const NAME: &'static str = "NotificationRequested";
pub const VERSION: i32 = 1;
pub fn record(
&self,
aggregate: &str,
aggregate_id: &str,
company_id: Uuid,
ctx: &WriteCtx,
) -> OutboxRecord {
let mut record = OutboxRecord::new(
Self::NAME,
aggregate,
aggregate_id,
company_id,
serde_json::to_value(self).unwrap_or(Value::Null),
chrono::Utc::now(),
)
.with_version(Self::VERSION)
.with_correlation_id(ctx.correlation_id.clone());
if let Some(cause) = &ctx.causation_id {
record = record.with_causation_id(cause.clone());
}
record
}
}
#[async_trait]
pub trait WriteHooks<E: PersistentEntity>: Send + Sync {
fn outbox_schema(&self) -> Option<&str> {
None
}
async fn before(&self, ctx: &WriteCtx, change: &mut Change<'_, E>) -> ServiceResult<()> {
let _ = (ctx, change);
Ok(())
}
async fn after(&self, ctx: &WriteCtx, change: &Change<'_, E>) -> ServiceResult<()> {
let _ = (ctx, change);
Ok(())
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoWriteHooks;
#[async_trait]
impl<E: PersistentEntity> WriteHooks<E> for NoWriteHooks {}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Row {
id: String,
status: String,
note: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
deleted_at: Option<DateTime<Utc>>,
}
impl PersistentEntity for Row {
fn state_fields() -> &'static [&'static str] {
&["status"]
}
fn entity_id(&self) -> String {
self.id.clone()
}
fn set_entity_id(&mut self, id: String) {
self.id = id;
}
fn created_at(&self) -> Option<DateTime<Utc>> {
Some(self.created_at)
}
fn set_created_at(&mut self, ts: DateTime<Utc>) {
self.created_at = ts;
}
fn updated_at(&self) -> Option<DateTime<Utc>> {
Some(self.updated_at)
}
fn set_updated_at(&mut self, ts: DateTime<Utc>) {
self.updated_at = ts;
}
fn deleted_at(&self) -> Option<DateTime<Utc>> {
self.deleted_at
}
fn set_deleted_at(&mut self, ts: Option<DateTime<Utc>>) {
self.deleted_at = ts;
}
}
fn row(status: &str, note: Option<&str>) -> Row {
Row {
id: "1".into(),
status: status.into(),
note: note.map(str::to_string),
created_at: Utc::now(),
updated_at: Utc::now(),
deleted_at: None,
}
}
#[tokio::test]
async fn an_update_is_a_transition_only_inside_a_verb() {
let before = row("open", None);
let mut after = row("closed", None);
assert_eq!(
Change::update(&before, &mut after).kind(),
ChangeKind::Updated
);
let kind = crate::write_guard::as_transition("close", async {
let mut after = row("closed", None);
match Change::update(&before, &mut after) {
Change::Transitioned {
transition,
field,
from,
to,
..
} => (transition, field, from, to),
other => panic!("expected a transition, got {:?}", other.kind()),
}
})
.await;
assert_eq!(
kind,
(
"close".to_string(),
"status".to_string(),
Some("open".to_string()),
Some("closed".to_string())
)
);
}
#[test]
fn a_field_counts_as_changed_only_when_its_value_differs() {
let before = row("open", Some("a"));
let mut same = row("open", Some("a"));
let mut note_changed = row("open", Some("b"));
let mut created = row("open", None);
assert!(!Change::Updated {
before: &before,
after: &mut same
}
.field_changed("note"));
assert!(Change::Updated {
before: &before,
after: &mut note_changed
}
.field_changed("note"));
assert!(!Change::Updated {
before: &before,
after: &mut note_changed
}
.field_changed("status"));
assert!(Change::Created {
after: &mut created
}
.field_changed("status"));
assert!(!Change::Created {
after: &mut row("open", None)
}
.field_changed("note"));
}
#[test]
fn a_hook_may_change_the_row_to_store_but_not_the_row_it_read() {
let before = row("open", None);
let mut after = row("open", None);
let mut change = Change::Updated {
before: &before,
after: &mut after,
};
{
let (old, entity) = change
.before_and_after_mut()
.expect("an update has both rows");
assert_eq!(old.status, "open");
entity.note = Some("filled".into());
}
assert_eq!(change.after().unwrap().note.as_deref(), Some("filled"));
assert_eq!(change.before().unwrap().note, None);
let mut created = row("open", None);
let mut change = Change::Created {
after: &mut created,
};
assert!(
change.before_and_after_mut().is_none(),
"a create has no row before it"
);
change.after_mut().unwrap().status = "new".into();
assert_eq!(change.subject().status, "new");
let gone = row("open", None);
let mut change = Change::Deleted {
before: &gone,
hard: false,
};
assert!(change.after_mut().is_none(), "a delete stores no row");
}
#[tokio::test]
async fn trigger_runs_nest_to_a_cap_and_the_run_past_it_is_refused_with_a_code() {
assert_eq!(current_trigger_depth(), 0);
fn nest(
level: u32,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ServiceResult<u32>> + Send>>
{
Box::pin(async move {
in_trigger(async move {
assert_eq!(current_trigger_depth(), level + 1);
match nest(level + 1).await {
Ok(deepest) => Ok(deepest),
Err(ServiceError::Violations(v)) if v[0].code == TRIGGER_DEPTH_EXCEEDED => {
Ok(level + 1)
}
Err(other) => Err(other),
}
})
.await
})
}
let deepest = nest(0).await.unwrap();
assert_eq!(
deepest, TRIGGER_MAX_DEPTH,
"{TRIGGER_MAX_DEPTH} nested runs ran, the next was refused"
);
assert_eq!(
current_trigger_depth(),
0,
"the depth does not leak out of the scope"
);
let refused = TRIGGER_DEPTH
.scope(TRIGGER_MAX_DEPTH, async {
in_trigger(async { Ok(()) }).await
})
.await
.unwrap_err();
match refused {
ServiceError::Violations(v) => {
assert_eq!(v[0].code, TRIGGER_DEPTH_EXCEEDED);
assert_eq!(v[0].params["max"], TRIGGER_MAX_DEPTH);
}
other => panic!("expected a violation, got {other:?}"),
}
}
#[test]
fn a_notification_request_is_an_outbox_row_about_the_aggregate_with_the_writes_ids() {
let ctx = WriteCtx {
correlation_id: "req-1".into(),
causation_id: Some("cause-1".into()),
actor: None,
company_id: None,
outbox_schema: Some("billing".into()),
};
let request = NotificationRequested {
channel: "email".into(),
template: Some("welcome".into()),
body: None,
to: serde_json::json!("a@b.test"),
data: serde_json::json!({ "name": "Ann" }),
trigger: "User.after_create".into(),
};
let record = request.record("User", "u-1", Uuid::from_u128(3), &ctx);
assert_eq!(
(
record.event_type.as_str(),
record.aggregate_type.as_str(),
record.aggregate_id.as_str(),
record.version
),
("NotificationRequested", "User", "u-1", 1)
);
assert_eq!(record.company_id, Uuid::from_u128(3));
assert_eq!(record.correlation_id.as_deref(), Some("req-1"));
assert_eq!(record.causation_id.as_deref(), Some("cause-1"));
assert_eq!(record.payload["channel"], "email");
assert_eq!(record.payload["to"], "a@b.test");
assert_eq!(record.payload["trigger"], "User.after_create");
assert_eq!(
serde_json::from_value::<NotificationRequested>(record.payload).unwrap(),
request
);
}
#[tokio::test]
async fn the_correlation_id_comes_from_the_request_scope_else_is_minted() {
let minted = WriteCtx::current(None);
assert!(!minted.correlation_id.is_empty());
let scoped =
with_correlation_id("req-7", async { WriteCtx::current(Some("billing")) }).await;
assert_eq!(scoped.correlation_id, "req-7");
assert_eq!(scoped.outbox_schema.as_deref(), Some("billing"));
}
async fn correlation_seen_by(header: Option<&str>) -> String {
use axum::{body::Body, http::Request, routing::get, Router};
use tower::ServiceExt;
let app = Router::new()
.route(
"/",
get(|| async { WriteCtx::current(None).correlation_id }),
)
.layer(axum::middleware::from_fn(
crate::http::correlation_middleware,
));
let mut req = Request::builder().uri("/");
if let Some(h) = header {
req = req.header("x-correlation-id", h);
}
let res = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap();
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn the_request_header_is_the_correlation_id_when_usable() {
assert_eq!(correlation_seen_by(Some("req-abc-1")).await, "req-abc-1");
for unusable in ["", "has space", &"x".repeat(129)] {
let id = correlation_seen_by(Some(unusable)).await;
assert!(Uuid::parse_str(&id).is_ok(), "{unusable:?} gave {id}");
}
assert!(Uuid::parse_str(&correlation_seen_by(None).await).is_ok());
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct RowOpened {
id: String,
note: Option<String>,
}
impl DeclaredEvent for RowOpened {
const NAME: &'static str = "RowOpened";
const VERSION: i32 = 3;
const AGGREGATE: &'static str = "Row";
}
#[test]
fn a_payload_is_picked_from_the_row_by_field_name() {
let row = serde_json::json!({ "id": "1", "note": null, "status": "open" });
let picked: RowOpened = payload_from_row(&row, &["id", "note"]).unwrap();
assert_eq!(
picked,
RowOpened {
id: "1".into(),
note: None
}
);
let thin = serde_json::json!({ "id": "1" });
assert_eq!(
payload_from_row::<RowOpened>(&thin, &["id", "note"])
.unwrap()
.note,
None
);
assert!(payload_from_row::<RowOpened>(&thin, &["note"]).is_err());
}
#[test]
fn a_declared_event_builds_its_outbox_row_from_its_declaration_and_the_context() {
let ctx = WriteCtx {
correlation_id: "req-9".into(),
causation_id: Some("cause-1".into()),
actor: None,
company_id: Some(Uuid::from_u128(7)),
outbox_schema: Some("billing".into()),
};
let event = RowOpened {
id: "1".into(),
note: Some("n".into()),
};
let record = event.record("agg-1", &ctx);
assert_eq!(
(
record.event_type.as_str(),
record.aggregate_type.as_str(),
record.aggregate_id.as_str(),
record.version
),
("RowOpened", "Row", "agg-1", 3)
);
assert_eq!(record.company_id, Uuid::from_u128(7));
assert_eq!(record.correlation_id.as_deref(), Some("req-9"));
assert_eq!(record.causation_id.as_deref(), Some("cause-1"));
assert_eq!(record.payload["note"], "n");
let own = Uuid::from_u128(9);
let row = serde_json::json!({ "company_id": own.to_string() });
assert_eq!(ctx.company_of(&row), own);
assert_eq!(ctx.company_of(&serde_json::json!({})), Uuid::from_u128(7));
}
}