1use async_trait::async_trait;
34use serde::{Deserialize, Serialize};
35use serde_json::Value;
36use uuid::Uuid;
37
38pub use backbone_outbox::OutboxRecord;
39
40use crate::persistence::traits::PersistentEntity;
41use crate::service::{ServiceError, ServiceResult};
42
43tokio::task_local! {
44 static CORRELATION: String;
45}
46
47pub async fn with_correlation_id<F: std::future::Future>(id: impl Into<String>, f: F) -> F::Output {
52 CORRELATION.scope(id.into(), f).await
53}
54
55pub fn current_correlation_id() -> Option<String> {
57 CORRELATION.try_with(|id| id.clone()).ok()
58}
59
60#[derive(Debug, Clone)]
62#[non_exhaustive]
63pub struct WriteCtx {
64 pub correlation_id: String,
67 pub causation_id: Option<String>,
70 pub actor: Option<String>,
72 pub company_id: Option<Uuid>,
75 pub outbox_schema: Option<String>,
78}
79
80impl WriteCtx {
81 pub fn current(outbox_schema: Option<&str>) -> Self {
83 let audit = backbone_orm::audit_context::current_request_audit();
84 let actor = audit
85 .as_ref()
86 .map(|a| a.actor.clone())
87 .filter(|a| !a.is_empty());
88 let correlation_id = current_correlation_id()
89 .or_else(|| audit.map(|a| a.correlation_id).filter(|c| !c.is_empty()))
90 .unwrap_or_else(|| Uuid::new_v4().to_string());
91 Self {
92 correlation_id,
93 causation_id: None,
94 actor,
95 company_id: backbone_orm::current_company(),
96 outbox_schema: outbox_schema.map(str::to_string),
97 }
98 }
99
100 pub fn company_or_nil(&self) -> Uuid {
104 self.company_id.unwrap_or_else(Uuid::nil)
105 }
106
107 pub fn company_of(&self, row: &Value) -> Uuid {
110 row.get("company_id")
111 .and_then(Value::as_str)
112 .and_then(|id| Uuid::parse_str(id).ok())
113 .unwrap_or_else(|| self.company_or_nil())
114 }
115
116 pub async fn stage(&self, mut record: OutboxRecord) -> ServiceResult<()> {
126 let Some(schema) = self.outbox_schema.as_deref() else {
127 return Err(ServiceError::Internal(
128 "an event was staged by hooks that name no outbox schema".into(),
129 ));
130 };
131 if record.correlation_id.is_none() {
132 record.correlation_id = Some(self.correlation_id.clone());
133 }
134 if record.causation_id.is_none() {
135 record.causation_id = self.causation_id.clone();
136 }
137 backbone_outbox::outbox::stage_in_write_tx(schema, &record)
138 .await
139 .map(|_| ())
140 .map_err(|e| {
141 ServiceError::Internal(format!("staging {} failed: {e}", record.event_type))
142 })
143 }
144}
145
146pub trait DeclaredEvent: serde::Serialize {
155 const NAME: &'static str;
157 const VERSION: i32;
159 const AGGREGATE: &'static str;
161
162 fn record(&self, aggregate_id: &str, ctx: &WriteCtx) -> OutboxRecord {
166 self.record_for(aggregate_id, ctx.company_or_nil(), ctx)
167 }
168
169 fn record_for(&self, aggregate_id: &str, company_id: Uuid, ctx: &WriteCtx) -> OutboxRecord {
171 let payload = serde_json::to_value(self).unwrap_or(Value::Null);
172 let mut record = OutboxRecord::new(
173 Self::NAME,
174 Self::AGGREGATE,
175 aggregate_id,
176 company_id,
177 payload,
178 chrono::Utc::now(),
179 )
180 .with_version(Self::VERSION)
181 .with_correlation_id(ctx.correlation_id.clone());
182 if let Some(cause) = &ctx.causation_id {
183 record = record.with_causation_id(cause.clone());
184 }
185 record
186 }
187}
188
189pub fn payload_from_row<T: serde::de::DeserializeOwned>(
194 row: &Value,
195 fields: &[&str],
196) -> ServiceResult<T> {
197 let mut picked = serde_json::Map::new();
198 if let Value::Object(map) = row {
199 for field in fields {
200 if let Some(value) = map.get(*field) {
201 picked.insert((*field).to_string(), value.clone());
202 }
203 }
204 }
205 serde_json::from_value(Value::Object(picked)).map_err(|e| {
206 ServiceError::Internal(format!(
207 "an event payload could not be read from the row: {e}"
208 ))
209 })
210}
211
212pub async fn migrate_outbox(pool: &backbone_orm::PgPool, schema: &str) -> anyhow::Result<()> {
216 backbone_outbox::outbox::migrate(pool, schema).await?;
217 Ok(())
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum ChangeKind {
223 Created,
225 Updated,
227 Transitioned,
229 Deleted,
231 Restored,
233}
234
235#[derive(Debug)]
241#[non_exhaustive]
242pub enum Change<'a, E> {
243 Created {
245 after: &'a mut E,
247 },
248 Updated {
250 before: &'a E,
252 after: &'a mut E,
254 },
255 Transitioned {
257 before: &'a E,
259 after: &'a mut E,
261 transition: String,
263 field: String,
265 from: Option<String>,
267 to: Option<String>,
269 },
270 Deleted {
272 before: &'a E,
274 hard: bool,
276 },
277 Restored {
279 entity: &'a E,
281 },
282}
283
284impl<'a, E> Change<'a, E> {
285 pub fn kind(&self) -> ChangeKind {
287 match self {
288 Change::Created { .. } => ChangeKind::Created,
289 Change::Updated { .. } => ChangeKind::Updated,
290 Change::Transitioned { .. } => ChangeKind::Transitioned,
291 Change::Deleted { .. } => ChangeKind::Deleted,
292 Change::Restored { .. } => ChangeKind::Restored,
293 }
294 }
295
296 pub fn before(&self) -> Option<&E> {
298 match self {
299 Change::Updated { before, .. }
300 | Change::Transitioned { before, .. }
301 | Change::Deleted { before, .. } => Some(before),
302 Change::Created { .. } | Change::Restored { .. } => None,
303 }
304 }
305
306 pub fn after(&self) -> Option<&E> {
308 match self {
309 Change::Created { after }
310 | Change::Updated { after, .. }
311 | Change::Transitioned { after, .. } => Some(after),
312 Change::Restored { entity } => Some(entity),
313 Change::Deleted { .. } => None,
314 }
315 }
316
317 pub fn subject(&self) -> &E {
319 match self {
320 Change::Deleted { before, .. } => before,
321 other => other
322 .after()
323 .expect("every change but a delete stores a row"),
324 }
325 }
326}
327
328impl<'a, E> Change<'a, E> {
329 pub fn after_mut(&mut self) -> Option<&mut E> {
332 match self {
333 Change::Created { after }
334 | Change::Updated { after, .. }
335 | Change::Transitioned { after, .. } => Some(&mut **after),
336 Change::Deleted { .. } | Change::Restored { .. } => None,
337 }
338 }
339
340 pub fn before_and_after_mut(&mut self) -> Option<(&E, &mut E)> {
343 match self {
344 Change::Updated { before, after } | Change::Transitioned { before, after, .. } => {
345 Some((*before, &mut **after))
346 }
347 _ => None,
348 }
349 }
350}
351
352impl<'a, E: serde::Serialize> Change<'a, E> {
353 pub fn field_changed(&self, name: &str) -> bool {
356 let after = match self.after().and_then(|a| serde_json::to_value(a).ok()) {
357 Some(Value::Object(map)) => map,
358 _ => return false,
359 };
360 let now = after.get(name).filter(|v| !v.is_null());
361 match self.before().and_then(|b| serde_json::to_value(b).ok()) {
362 Some(Value::Object(before)) => before.get(name).filter(|v| !v.is_null()) != now,
363 _ => now.is_some(),
364 }
365 }
366}
367
368impl<'a, E: PersistentEntity> Change<'a, E> {
369 pub(crate) fn update(before: &'a E, after: &'a mut E) -> Self {
372 let Some(transition) = crate::write_guard::current_transition() else {
373 return Change::Updated { before, after };
374 };
375 let before_obj = serde_json::to_value(before).ok();
376 let after_obj = serde_json::to_value(&*after).ok();
377 let state = |obj: &Option<Value>, field: &str| {
378 obj.as_ref()
379 .and_then(|o| o.get(field))
380 .and_then(Value::as_str)
381 .map(str::to_string)
382 };
383 let moved = E::state_fields()
385 .iter()
386 .find(|f| state(&before_obj, f) != state(&after_obj, f))
387 .or_else(|| E::state_fields().first())
388 .copied();
389 match moved {
390 Some(field) => Change::Transitioned {
391 from: state(&before_obj, field),
392 to: state(&after_obj, field),
393 field: field.to_string(),
394 transition,
395 before,
396 after,
397 },
398 None => Change::Updated { before, after },
400 }
401 }
402}
403
404pub const TRIGGER_MAX_DEPTH: u32 = 8;
410
411pub const TRIGGER_DEPTH_EXCEEDED: &str = "trigger_depth_exceeded";
413
414tokio::task_local! {
415 static TRIGGER_DEPTH: u32;
416}
417
418pub fn current_trigger_depth() -> u32 {
420 TRIGGER_DEPTH.try_with(|d| *d).unwrap_or(0)
421}
422
423pub async fn in_trigger<T, F>(f: F) -> ServiceResult<T>
429where
430 F: std::future::Future<Output = ServiceResult<T>>,
431{
432 let depth = current_trigger_depth();
433 if depth >= TRIGGER_MAX_DEPTH {
434 return Err(ServiceError::Violations(vec![crate::violation::Violation::new(
435 "",
436 TRIGGER_DEPTH_EXCEEDED,
437 format!("triggers are nested more than {TRIGGER_MAX_DEPTH} deep: a trigger writes a row whose trigger writes a row, and so on"),
438 )
439 .with_param("max", TRIGGER_MAX_DEPTH)]));
440 }
441 TRIGGER_DEPTH.scope(depth + 1, f).await
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub struct ScheduledTrigger {
448 pub name: &'static str,
450 pub schedule: &'static str,
452 pub description: &'static str,
454}
455
456#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
466pub struct NotificationRequested {
467 pub channel: String,
469 pub template: Option<String>,
471 pub body: Option<String>,
473 pub to: Value,
475 pub data: Value,
477 pub trigger: String,
479}
480
481impl NotificationRequested {
482 pub const NAME: &'static str = "NotificationRequested";
484 pub const VERSION: i32 = 1;
486
487 pub fn record(
490 &self,
491 aggregate: &str,
492 aggregate_id: &str,
493 company_id: Uuid,
494 ctx: &WriteCtx,
495 ) -> OutboxRecord {
496 let mut record = OutboxRecord::new(
497 Self::NAME,
498 aggregate,
499 aggregate_id,
500 company_id,
501 serde_json::to_value(self).unwrap_or(Value::Null),
502 chrono::Utc::now(),
503 )
504 .with_version(Self::VERSION)
505 .with_correlation_id(ctx.correlation_id.clone());
506 if let Some(cause) = &ctx.causation_id {
507 record = record.with_causation_id(cause.clone());
508 }
509 record
510 }
511}
512
513#[async_trait]
518pub trait WriteHooks<E: PersistentEntity>: Send + Sync {
519 fn outbox_schema(&self) -> Option<&str> {
522 None
523 }
524
525 async fn before(&self, ctx: &WriteCtx, change: &mut Change<'_, E>) -> ServiceResult<()> {
528 let _ = (ctx, change);
529 Ok(())
530 }
531
532 async fn after(&self, ctx: &WriteCtx, change: &Change<'_, E>) -> ServiceResult<()> {
536 let _ = (ctx, change);
537 Ok(())
538 }
539}
540
541#[derive(Debug, Default, Clone, Copy)]
543pub struct NoWriteHooks;
544
545#[async_trait]
546impl<E: PersistentEntity> WriteHooks<E> for NoWriteHooks {}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use chrono::{DateTime, Utc};
552 use serde::{Deserialize, Serialize};
553
554 #[derive(Debug, Clone, Serialize, Deserialize)]
555 struct Row {
556 id: String,
557 status: String,
558 note: Option<String>,
559 created_at: DateTime<Utc>,
560 updated_at: DateTime<Utc>,
561 deleted_at: Option<DateTime<Utc>>,
562 }
563
564 impl PersistentEntity for Row {
565 fn state_fields() -> &'static [&'static str] {
566 &["status"]
567 }
568 fn entity_id(&self) -> String {
569 self.id.clone()
570 }
571 fn set_entity_id(&mut self, id: String) {
572 self.id = id;
573 }
574 fn created_at(&self) -> Option<DateTime<Utc>> {
575 Some(self.created_at)
576 }
577 fn set_created_at(&mut self, ts: DateTime<Utc>) {
578 self.created_at = ts;
579 }
580 fn updated_at(&self) -> Option<DateTime<Utc>> {
581 Some(self.updated_at)
582 }
583 fn set_updated_at(&mut self, ts: DateTime<Utc>) {
584 self.updated_at = ts;
585 }
586 fn deleted_at(&self) -> Option<DateTime<Utc>> {
587 self.deleted_at
588 }
589 fn set_deleted_at(&mut self, ts: Option<DateTime<Utc>>) {
590 self.deleted_at = ts;
591 }
592 }
593
594 fn row(status: &str, note: Option<&str>) -> Row {
595 Row {
596 id: "1".into(),
597 status: status.into(),
598 note: note.map(str::to_string),
599 created_at: Utc::now(),
600 updated_at: Utc::now(),
601 deleted_at: None,
602 }
603 }
604
605 #[tokio::test]
606 async fn an_update_is_a_transition_only_inside_a_verb() {
607 let before = row("open", None);
608 let mut after = row("closed", None);
609 assert_eq!(
610 Change::update(&before, &mut after).kind(),
611 ChangeKind::Updated
612 );
613
614 let kind = crate::write_guard::as_transition("close", async {
615 let mut after = row("closed", None);
616 match Change::update(&before, &mut after) {
617 Change::Transitioned {
618 transition,
619 field,
620 from,
621 to,
622 ..
623 } => (transition, field, from, to),
624 other => panic!("expected a transition, got {:?}", other.kind()),
625 }
626 })
627 .await;
628 assert_eq!(
629 kind,
630 (
631 "close".to_string(),
632 "status".to_string(),
633 Some("open".to_string()),
634 Some("closed".to_string())
635 )
636 );
637 }
638
639 #[test]
640 fn a_field_counts_as_changed_only_when_its_value_differs() {
641 let before = row("open", Some("a"));
642 let mut same = row("open", Some("a"));
643 let mut note_changed = row("open", Some("b"));
644 let mut created = row("open", None);
645 assert!(!Change::Updated {
646 before: &before,
647 after: &mut same
648 }
649 .field_changed("note"));
650 assert!(Change::Updated {
651 before: &before,
652 after: &mut note_changed
653 }
654 .field_changed("note"));
655 assert!(!Change::Updated {
656 before: &before,
657 after: &mut note_changed
658 }
659 .field_changed("status"));
660 assert!(Change::Created {
662 after: &mut created
663 }
664 .field_changed("status"));
665 assert!(!Change::Created {
666 after: &mut row("open", None)
667 }
668 .field_changed("note"));
669 }
670
671 #[test]
672 fn a_hook_may_change_the_row_to_store_but_not_the_row_it_read() {
673 let before = row("open", None);
674 let mut after = row("open", None);
675 let mut change = Change::Updated {
676 before: &before,
677 after: &mut after,
678 };
679 {
680 let (old, entity) = change
681 .before_and_after_mut()
682 .expect("an update has both rows");
683 assert_eq!(old.status, "open");
684 entity.note = Some("filled".into());
685 }
686 assert_eq!(change.after().unwrap().note.as_deref(), Some("filled"));
687 assert_eq!(change.before().unwrap().note, None);
688
689 let mut created = row("open", None);
690 let mut change = Change::Created {
691 after: &mut created,
692 };
693 assert!(
694 change.before_and_after_mut().is_none(),
695 "a create has no row before it"
696 );
697 change.after_mut().unwrap().status = "new".into();
698 assert_eq!(change.subject().status, "new");
699
700 let gone = row("open", None);
701 let mut change = Change::Deleted {
702 before: &gone,
703 hard: false,
704 };
705 assert!(change.after_mut().is_none(), "a delete stores no row");
706 }
707
708 #[tokio::test]
709 async fn trigger_runs_nest_to_a_cap_and_the_run_past_it_is_refused_with_a_code() {
710 assert_eq!(current_trigger_depth(), 0);
711 fn nest(
713 level: u32,
714 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ServiceResult<u32>> + Send>>
715 {
716 Box::pin(async move {
717 in_trigger(async move {
718 assert_eq!(current_trigger_depth(), level + 1);
719 match nest(level + 1).await {
720 Ok(deepest) => Ok(deepest),
721 Err(ServiceError::Violations(v)) if v[0].code == TRIGGER_DEPTH_EXCEEDED => {
722 Ok(level + 1)
723 }
724 Err(other) => Err(other),
725 }
726 })
727 .await
728 })
729 }
730 let deepest = nest(0).await.unwrap();
731 assert_eq!(
732 deepest, TRIGGER_MAX_DEPTH,
733 "{TRIGGER_MAX_DEPTH} nested runs ran, the next was refused"
734 );
735 assert_eq!(
736 current_trigger_depth(),
737 0,
738 "the depth does not leak out of the scope"
739 );
740
741 let refused = TRIGGER_DEPTH
743 .scope(TRIGGER_MAX_DEPTH, async {
744 in_trigger(async { Ok(()) }).await
745 })
746 .await
747 .unwrap_err();
748 match refused {
749 ServiceError::Violations(v) => {
750 assert_eq!(v[0].code, TRIGGER_DEPTH_EXCEEDED);
751 assert_eq!(v[0].params["max"], TRIGGER_MAX_DEPTH);
752 }
753 other => panic!("expected a violation, got {other:?}"),
754 }
755 }
756
757 #[test]
758 fn a_notification_request_is_an_outbox_row_about_the_aggregate_with_the_writes_ids() {
759 let ctx = WriteCtx {
760 correlation_id: "req-1".into(),
761 causation_id: Some("cause-1".into()),
762 actor: None,
763 company_id: None,
764 outbox_schema: Some("billing".into()),
765 };
766 let request = NotificationRequested {
767 channel: "email".into(),
768 template: Some("welcome".into()),
769 body: None,
770 to: serde_json::json!("a@b.test"),
771 data: serde_json::json!({ "name": "Ann" }),
772 trigger: "User.after_create".into(),
773 };
774 let record = request.record("User", "u-1", Uuid::from_u128(3), &ctx);
775 assert_eq!(
776 (
777 record.event_type.as_str(),
778 record.aggregate_type.as_str(),
779 record.aggregate_id.as_str(),
780 record.version
781 ),
782 ("NotificationRequested", "User", "u-1", 1)
783 );
784 assert_eq!(record.company_id, Uuid::from_u128(3));
785 assert_eq!(record.correlation_id.as_deref(), Some("req-1"));
786 assert_eq!(record.causation_id.as_deref(), Some("cause-1"));
787 assert_eq!(record.payload["channel"], "email");
788 assert_eq!(record.payload["to"], "a@b.test");
789 assert_eq!(record.payload["trigger"], "User.after_create");
790 assert_eq!(
791 serde_json::from_value::<NotificationRequested>(record.payload).unwrap(),
792 request
793 );
794 }
795
796 #[tokio::test]
797 async fn the_correlation_id_comes_from_the_request_scope_else_is_minted() {
798 let minted = WriteCtx::current(None);
799 assert!(!minted.correlation_id.is_empty());
800 let scoped =
801 with_correlation_id("req-7", async { WriteCtx::current(Some("billing")) }).await;
802 assert_eq!(scoped.correlation_id, "req-7");
803 assert_eq!(scoped.outbox_schema.as_deref(), Some("billing"));
804 }
805
806 async fn correlation_seen_by(header: Option<&str>) -> String {
807 use axum::{body::Body, http::Request, routing::get, Router};
808 use tower::ServiceExt;
809 let app = Router::new()
810 .route(
811 "/",
812 get(|| async { WriteCtx::current(None).correlation_id }),
813 )
814 .layer(axum::middleware::from_fn(
815 crate::http::correlation_middleware,
816 ));
817 let mut req = Request::builder().uri("/");
818 if let Some(h) = header {
819 req = req.header("x-correlation-id", h);
820 }
821 let res = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap();
822 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
823 .await
824 .unwrap();
825 String::from_utf8(bytes.to_vec()).unwrap()
826 }
827
828 #[tokio::test]
829 async fn the_request_header_is_the_correlation_id_when_usable() {
830 assert_eq!(correlation_seen_by(Some("req-abc-1")).await, "req-abc-1");
831 for unusable in ["", "has space", &"x".repeat(129)] {
832 let id = correlation_seen_by(Some(unusable)).await;
833 assert!(Uuid::parse_str(&id).is_ok(), "{unusable:?} gave {id}");
834 }
835 assert!(Uuid::parse_str(&correlation_seen_by(None).await).is_ok());
836 }
837
838 #[derive(Serialize, Deserialize, Debug, PartialEq)]
839 struct RowOpened {
840 id: String,
841 note: Option<String>,
842 }
843
844 impl DeclaredEvent for RowOpened {
845 const NAME: &'static str = "RowOpened";
846 const VERSION: i32 = 3;
847 const AGGREGATE: &'static str = "Row";
848 }
849
850 #[test]
851 fn a_payload_is_picked_from_the_row_by_field_name() {
852 let row = serde_json::json!({ "id": "1", "note": null, "status": "open" });
853 let picked: RowOpened = payload_from_row(&row, &["id", "note"]).unwrap();
854 assert_eq!(
855 picked,
856 RowOpened {
857 id: "1".into(),
858 note: None
859 }
860 );
861 let thin = serde_json::json!({ "id": "1" });
863 assert_eq!(
864 payload_from_row::<RowOpened>(&thin, &["id", "note"])
865 .unwrap()
866 .note,
867 None
868 );
869 assert!(payload_from_row::<RowOpened>(&thin, &["note"]).is_err());
870 }
871
872 #[test]
873 fn a_declared_event_builds_its_outbox_row_from_its_declaration_and_the_context() {
874 let ctx = WriteCtx {
875 correlation_id: "req-9".into(),
876 causation_id: Some("cause-1".into()),
877 actor: None,
878 company_id: Some(Uuid::from_u128(7)),
879 outbox_schema: Some("billing".into()),
880 };
881 let event = RowOpened {
882 id: "1".into(),
883 note: Some("n".into()),
884 };
885 let record = event.record("agg-1", &ctx);
886 assert_eq!(
887 (
888 record.event_type.as_str(),
889 record.aggregate_type.as_str(),
890 record.aggregate_id.as_str(),
891 record.version
892 ),
893 ("RowOpened", "Row", "agg-1", 3)
894 );
895 assert_eq!(record.company_id, Uuid::from_u128(7));
896 assert_eq!(record.correlation_id.as_deref(), Some("req-9"));
897 assert_eq!(record.causation_id.as_deref(), Some("cause-1"));
898 assert_eq!(record.payload["note"], "n");
899 let own = Uuid::from_u128(9);
901 let row = serde_json::json!({ "company_id": own.to_string() });
902 assert_eq!(ctx.company_of(&row), own);
903 assert_eq!(ctx.company_of(&serde_json::json!({})), Uuid::from_u128(7));
904 }
905}