1use async_trait::async_trait;
34use serde_json::Value;
35use uuid::Uuid;
36
37pub use backbone_outbox::OutboxRecord;
38
39use crate::persistence::traits::PersistentEntity;
40use crate::service::{ServiceError, ServiceResult};
41
42tokio::task_local! {
43 static CORRELATION: String;
44}
45
46pub async fn with_correlation_id<F: std::future::Future>(id: impl Into<String>, f: F) -> F::Output {
51 CORRELATION.scope(id.into(), f).await
52}
53
54pub fn current_correlation_id() -> Option<String> {
56 CORRELATION.try_with(|id| id.clone()).ok()
57}
58
59#[derive(Debug, Clone)]
61#[non_exhaustive]
62pub struct WriteCtx {
63 pub correlation_id: String,
66 pub causation_id: Option<String>,
69 pub actor: Option<String>,
71 pub company_id: Option<Uuid>,
74 pub outbox_schema: Option<String>,
77}
78
79impl WriteCtx {
80 pub fn current(outbox_schema: Option<&str>) -> Self {
82 let audit = backbone_orm::audit_context::current_request_audit();
83 let actor = audit
84 .as_ref()
85 .map(|a| a.actor.clone())
86 .filter(|a| !a.is_empty());
87 let correlation_id = current_correlation_id()
88 .or_else(|| audit.map(|a| a.correlation_id).filter(|c| !c.is_empty()))
89 .unwrap_or_else(|| Uuid::new_v4().to_string());
90 Self {
91 correlation_id,
92 causation_id: None,
93 actor,
94 company_id: backbone_orm::current_company(),
95 outbox_schema: outbox_schema.map(str::to_string),
96 }
97 }
98
99 pub fn company_or_nil(&self) -> Uuid {
103 self.company_id.unwrap_or_else(Uuid::nil)
104 }
105
106 pub fn company_of(&self, row: &Value) -> Uuid {
109 row.get("company_id")
110 .and_then(Value::as_str)
111 .and_then(|id| Uuid::parse_str(id).ok())
112 .unwrap_or_else(|| self.company_or_nil())
113 }
114
115 pub async fn stage(&self, mut record: OutboxRecord) -> ServiceResult<()> {
125 let Some(schema) = self.outbox_schema.as_deref() else {
126 return Err(ServiceError::Internal(
127 "an event was staged by hooks that name no outbox schema".into(),
128 ));
129 };
130 if record.correlation_id.is_none() {
131 record.correlation_id = Some(self.correlation_id.clone());
132 }
133 if record.causation_id.is_none() {
134 record.causation_id = self.causation_id.clone();
135 }
136 backbone_outbox::outbox::stage_in_write_tx(schema, &record)
137 .await
138 .map(|_| ())
139 .map_err(|e| {
140 ServiceError::Internal(format!("staging {} failed: {e}", record.event_type))
141 })
142 }
143}
144
145pub trait DeclaredEvent: serde::Serialize {
154 const NAME: &'static str;
156 const VERSION: i32;
158 const AGGREGATE: &'static str;
160
161 fn record(&self, aggregate_id: &str, ctx: &WriteCtx) -> OutboxRecord {
165 self.record_for(aggregate_id, ctx.company_or_nil(), ctx)
166 }
167
168 fn record_for(&self, aggregate_id: &str, company_id: Uuid, ctx: &WriteCtx) -> OutboxRecord {
170 let payload = serde_json::to_value(self).unwrap_or(Value::Null);
171 let mut record = OutboxRecord::new(
172 Self::NAME,
173 Self::AGGREGATE,
174 aggregate_id,
175 company_id,
176 payload,
177 chrono::Utc::now(),
178 )
179 .with_version(Self::VERSION)
180 .with_correlation_id(ctx.correlation_id.clone());
181 if let Some(cause) = &ctx.causation_id {
182 record = record.with_causation_id(cause.clone());
183 }
184 record
185 }
186}
187
188pub fn payload_from_row<T: serde::de::DeserializeOwned>(
193 row: &Value,
194 fields: &[&str],
195) -> ServiceResult<T> {
196 let mut picked = serde_json::Map::new();
197 if let Value::Object(map) = row {
198 for field in fields {
199 if let Some(value) = map.get(*field) {
200 picked.insert((*field).to_string(), value.clone());
201 }
202 }
203 }
204 serde_json::from_value(Value::Object(picked)).map_err(|e| {
205 ServiceError::Internal(format!(
206 "an event payload could not be read from the row: {e}"
207 ))
208 })
209}
210
211pub async fn migrate_outbox(pool: &backbone_orm::PgPool, schema: &str) -> anyhow::Result<()> {
215 backbone_outbox::outbox::migrate(pool, schema).await?;
216 Ok(())
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum ChangeKind {
222 Created,
224 Updated,
226 Transitioned,
228 Deleted,
230 Restored,
232}
233
234#[derive(Debug)]
240#[non_exhaustive]
241pub enum Change<'a, E> {
242 Created {
244 after: &'a mut E,
246 },
247 Updated {
249 before: &'a E,
251 after: &'a mut E,
253 },
254 Transitioned {
256 before: &'a E,
258 after: &'a mut E,
260 transition: String,
262 field: String,
264 from: Option<String>,
266 to: Option<String>,
268 },
269 Deleted {
271 before: &'a E,
273 hard: bool,
275 },
276 Restored {
278 entity: &'a E,
280 },
281}
282
283impl<'a, E> Change<'a, E> {
284 pub fn kind(&self) -> ChangeKind {
286 match self {
287 Change::Created { .. } => ChangeKind::Created,
288 Change::Updated { .. } => ChangeKind::Updated,
289 Change::Transitioned { .. } => ChangeKind::Transitioned,
290 Change::Deleted { .. } => ChangeKind::Deleted,
291 Change::Restored { .. } => ChangeKind::Restored,
292 }
293 }
294
295 pub fn before(&self) -> Option<&E> {
297 match self {
298 Change::Updated { before, .. }
299 | Change::Transitioned { before, .. }
300 | Change::Deleted { before, .. } => Some(before),
301 Change::Created { .. } | Change::Restored { .. } => None,
302 }
303 }
304
305 pub fn after(&self) -> Option<&E> {
307 match self {
308 Change::Created { after }
309 | Change::Updated { after, .. }
310 | Change::Transitioned { after, .. } => Some(after),
311 Change::Restored { entity } => Some(entity),
312 Change::Deleted { .. } => None,
313 }
314 }
315
316 pub fn subject(&self) -> &E {
318 match self {
319 Change::Deleted { before, .. } => before,
320 other => other
321 .after()
322 .expect("every change but a delete stores a row"),
323 }
324 }
325}
326
327impl<'a, E: serde::Serialize> Change<'a, E> {
328 pub fn field_changed(&self, name: &str) -> bool {
331 let after = match self.after().and_then(|a| serde_json::to_value(a).ok()) {
332 Some(Value::Object(map)) => map,
333 _ => return false,
334 };
335 let now = after.get(name).filter(|v| !v.is_null());
336 match self.before().and_then(|b| serde_json::to_value(b).ok()) {
337 Some(Value::Object(before)) => before.get(name).filter(|v| !v.is_null()) != now,
338 _ => now.is_some(),
339 }
340 }
341}
342
343impl<'a, E: PersistentEntity> Change<'a, E> {
344 pub(crate) fn update(before: &'a E, after: &'a mut E) -> Self {
347 let Some(transition) = crate::write_guard::current_transition() else {
348 return Change::Updated { before, after };
349 };
350 let before_obj = serde_json::to_value(before).ok();
351 let after_obj = serde_json::to_value(&*after).ok();
352 let state = |obj: &Option<Value>, field: &str| {
353 obj.as_ref()
354 .and_then(|o| o.get(field))
355 .and_then(Value::as_str)
356 .map(str::to_string)
357 };
358 let moved = E::state_fields()
360 .iter()
361 .find(|f| state(&before_obj, f) != state(&after_obj, f))
362 .or_else(|| E::state_fields().first())
363 .copied();
364 match moved {
365 Some(field) => Change::Transitioned {
366 from: state(&before_obj, field),
367 to: state(&after_obj, field),
368 field: field.to_string(),
369 transition,
370 before,
371 after,
372 },
373 None => Change::Updated { before, after },
375 }
376 }
377}
378
379#[async_trait]
384pub trait WriteHooks<E: PersistentEntity>: Send + Sync {
385 fn outbox_schema(&self) -> Option<&str> {
388 None
389 }
390
391 async fn before(&self, ctx: &WriteCtx, change: &mut Change<'_, E>) -> ServiceResult<()> {
394 let _ = (ctx, change);
395 Ok(())
396 }
397
398 async fn after(&self, ctx: &WriteCtx, change: &Change<'_, E>) -> ServiceResult<()> {
402 let _ = (ctx, change);
403 Ok(())
404 }
405}
406
407#[derive(Debug, Default, Clone, Copy)]
409pub struct NoWriteHooks;
410
411#[async_trait]
412impl<E: PersistentEntity> WriteHooks<E> for NoWriteHooks {}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use chrono::{DateTime, Utc};
418 use serde::{Deserialize, Serialize};
419
420 #[derive(Debug, Clone, Serialize, Deserialize)]
421 struct Row {
422 id: String,
423 status: String,
424 note: Option<String>,
425 created_at: DateTime<Utc>,
426 updated_at: DateTime<Utc>,
427 deleted_at: Option<DateTime<Utc>>,
428 }
429
430 impl PersistentEntity for Row {
431 fn state_fields() -> &'static [&'static str] {
432 &["status"]
433 }
434 fn entity_id(&self) -> String {
435 self.id.clone()
436 }
437 fn set_entity_id(&mut self, id: String) {
438 self.id = id;
439 }
440 fn created_at(&self) -> Option<DateTime<Utc>> {
441 Some(self.created_at)
442 }
443 fn set_created_at(&mut self, ts: DateTime<Utc>) {
444 self.created_at = ts;
445 }
446 fn updated_at(&self) -> Option<DateTime<Utc>> {
447 Some(self.updated_at)
448 }
449 fn set_updated_at(&mut self, ts: DateTime<Utc>) {
450 self.updated_at = ts;
451 }
452 fn deleted_at(&self) -> Option<DateTime<Utc>> {
453 self.deleted_at
454 }
455 fn set_deleted_at(&mut self, ts: Option<DateTime<Utc>>) {
456 self.deleted_at = ts;
457 }
458 }
459
460 fn row(status: &str, note: Option<&str>) -> Row {
461 Row {
462 id: "1".into(),
463 status: status.into(),
464 note: note.map(str::to_string),
465 created_at: Utc::now(),
466 updated_at: Utc::now(),
467 deleted_at: None,
468 }
469 }
470
471 #[tokio::test]
472 async fn an_update_is_a_transition_only_inside_a_verb() {
473 let before = row("open", None);
474 let mut after = row("closed", None);
475 assert_eq!(
476 Change::update(&before, &mut after).kind(),
477 ChangeKind::Updated
478 );
479
480 let kind = crate::write_guard::as_transition("close", async {
481 let mut after = row("closed", None);
482 match Change::update(&before, &mut after) {
483 Change::Transitioned {
484 transition,
485 field,
486 from,
487 to,
488 ..
489 } => (transition, field, from, to),
490 other => panic!("expected a transition, got {:?}", other.kind()),
491 }
492 })
493 .await;
494 assert_eq!(
495 kind,
496 (
497 "close".to_string(),
498 "status".to_string(),
499 Some("open".to_string()),
500 Some("closed".to_string())
501 )
502 );
503 }
504
505 #[test]
506 fn a_field_counts_as_changed_only_when_its_value_differs() {
507 let before = row("open", Some("a"));
508 let mut same = row("open", Some("a"));
509 let mut note_changed = row("open", Some("b"));
510 let mut created = row("open", None);
511 assert!(!Change::Updated {
512 before: &before,
513 after: &mut same
514 }
515 .field_changed("note"));
516 assert!(Change::Updated {
517 before: &before,
518 after: &mut note_changed
519 }
520 .field_changed("note"));
521 assert!(!Change::Updated {
522 before: &before,
523 after: &mut note_changed
524 }
525 .field_changed("status"));
526 assert!(Change::Created {
528 after: &mut created
529 }
530 .field_changed("status"));
531 assert!(!Change::Created {
532 after: &mut row("open", None)
533 }
534 .field_changed("note"));
535 }
536
537 #[tokio::test]
538 async fn the_correlation_id_comes_from_the_request_scope_else_is_minted() {
539 let minted = WriteCtx::current(None);
540 assert!(!minted.correlation_id.is_empty());
541 let scoped =
542 with_correlation_id("req-7", async { WriteCtx::current(Some("billing")) }).await;
543 assert_eq!(scoped.correlation_id, "req-7");
544 assert_eq!(scoped.outbox_schema.as_deref(), Some("billing"));
545 }
546
547 async fn correlation_seen_by(header: Option<&str>) -> String {
548 use axum::{body::Body, http::Request, routing::get, Router};
549 use tower::ServiceExt;
550 let app = Router::new()
551 .route(
552 "/",
553 get(|| async { WriteCtx::current(None).correlation_id }),
554 )
555 .layer(axum::middleware::from_fn(
556 crate::http::correlation_middleware,
557 ));
558 let mut req = Request::builder().uri("/");
559 if let Some(h) = header {
560 req = req.header("x-correlation-id", h);
561 }
562 let res = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap();
563 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
564 .await
565 .unwrap();
566 String::from_utf8(bytes.to_vec()).unwrap()
567 }
568
569 #[tokio::test]
570 async fn the_request_header_is_the_correlation_id_when_usable() {
571 assert_eq!(correlation_seen_by(Some("req-abc-1")).await, "req-abc-1");
572 for unusable in ["", "has space", &"x".repeat(129)] {
573 let id = correlation_seen_by(Some(unusable)).await;
574 assert!(Uuid::parse_str(&id).is_ok(), "{unusable:?} gave {id}");
575 }
576 assert!(Uuid::parse_str(&correlation_seen_by(None).await).is_ok());
577 }
578
579 #[derive(Serialize, Deserialize, Debug, PartialEq)]
580 struct RowOpened {
581 id: String,
582 note: Option<String>,
583 }
584
585 impl DeclaredEvent for RowOpened {
586 const NAME: &'static str = "RowOpened";
587 const VERSION: i32 = 3;
588 const AGGREGATE: &'static str = "Row";
589 }
590
591 #[test]
592 fn a_payload_is_picked_from_the_row_by_field_name() {
593 let row = serde_json::json!({ "id": "1", "note": null, "status": "open" });
594 let picked: RowOpened = payload_from_row(&row, &["id", "note"]).unwrap();
595 assert_eq!(
596 picked,
597 RowOpened {
598 id: "1".into(),
599 note: None
600 }
601 );
602 let thin = serde_json::json!({ "id": "1" });
604 assert_eq!(
605 payload_from_row::<RowOpened>(&thin, &["id", "note"])
606 .unwrap()
607 .note,
608 None
609 );
610 assert!(payload_from_row::<RowOpened>(&thin, &["note"]).is_err());
611 }
612
613 #[test]
614 fn a_declared_event_builds_its_outbox_row_from_its_declaration_and_the_context() {
615 let ctx = WriteCtx {
616 correlation_id: "req-9".into(),
617 causation_id: Some("cause-1".into()),
618 actor: None,
619 company_id: Some(Uuid::from_u128(7)),
620 outbox_schema: Some("billing".into()),
621 };
622 let event = RowOpened {
623 id: "1".into(),
624 note: Some("n".into()),
625 };
626 let record = event.record("agg-1", &ctx);
627 assert_eq!(
628 (
629 record.event_type.as_str(),
630 record.aggregate_type.as_str(),
631 record.aggregate_id.as_str(),
632 record.version
633 ),
634 ("RowOpened", "Row", "agg-1", 3)
635 );
636 assert_eq!(record.company_id, Uuid::from_u128(7));
637 assert_eq!(record.correlation_id.as_deref(), Some("req-9"));
638 assert_eq!(record.causation_id.as_deref(), Some("cause-1"));
639 assert_eq!(record.payload["note"], "n");
640 let own = Uuid::from_u128(9);
642 let row = serde_json::json!({ "company_id": own.to_string() });
643 assert_eq!(ctx.company_of(&row), own);
644 assert_eq!(ctx.company_of(&serde_json::json!({})), Uuid::from_u128(7));
645 }
646}