Skip to main content

backbone_core/
write_hooks.rs

1//! Write hooks: code that runs inside a write's own transaction, with the change in hand.
2//!
3//! [`GenericCrudService`](crate::service::GenericCrudService) runs every write of an entity
4//! (create, update, patch, delete, restore, hard delete, their bulk forms, and a state-machine
5//! transition) inside one database transaction ([`backbone_orm::in_write_tx`]), in this order:
6//!
7//! 1. the write guard,
8//! 2. [`ServiceLifecycle::before_*`](crate::service::ServiceLifecycle),
9//! 3. [`WriteHooks::before`],
10//! 4. the repository write,
11//! 5. [`ServiceLifecycle::after_*`](crate::service::ServiceLifecycle),
12//! 6. [`WriteHooks::after`],
13//! 7. commit.
14//!
15//! A failure at any step rolls the whole write back: the row, whatever the hooks wrote and every
16//! event they staged. After the commit the service's [`CrudEventPublisher`] keeps its
17//! best-effort in-process role, and nothing else.
18//!
19//! The schema generator emits a `WriteHooks` implementation per model from its declared events
20//! (`on:` in a hook file's `events:`), which stages them with [`WriteCtx::stage`]. Hand code can
21//! implement the trait too, and compose it with [`with_write_hooks`](crate::service::GenericCrudService::with_write_hooks).
22//!
23//! # What a hook may rely on
24//!
25//! - Statements it runs through the ORM's scoped helpers, the repository, or
26//!   [`WriteCtx::stage`] ride the write's transaction and see its uncommitted rows.
27//! - A connection a hook opens itself (a bare `pool.begin()`) is another transaction: it cannot
28//!   see the row being written, and a statement of it that waits for a lock the write holds
29//!   waits for a commit that is waiting for the hook.
30//! - A hook that holds the transaction open for a slow call (HTTP, a queue) holds a database
31//!   connection and the row locks for as long. Stage an event and let the relay do the slow part.
32
33use 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
47/// Run `f` with `id` as the correlation id of every write it makes: the id the events staged by
48/// those writes carry, so one request's rows, events and logs share it. The generic write routes
49/// do this from the `x-correlation-id` request header (see
50/// [`correlation_middleware`](crate::http::correlation_middleware)).
51pub 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
55/// The correlation id [`with_correlation_id`] bound for the current task.
56pub fn current_correlation_id() -> Option<String> {
57    CORRELATION.try_with(|id| id.clone()).ok()
58}
59
60/// What a write hook knows about the write it runs in.
61#[derive(Debug, Clone)]
62#[non_exhaustive]
63pub struct WriteCtx {
64    /// The correlation id of the request (its `x-correlation-id` header, else the audit
65    /// attribution's, else one minted for the write). Staged events carry it.
66    pub correlation_id: String,
67    /// The id of the event, trigger or workflow step that caused this write, when one did.
68    /// Staged events carry it as their `causation_id`.
69    pub causation_id: Option<String>,
70    /// Who is writing: the request's audit attribution; `None` for a job or an unscoped caller.
71    pub actor: Option<String>,
72    /// The company the request is scoped to, when it is. Events of an entity with no company of
73    /// its own are staged under it.
74    pub company_id: Option<Uuid>,
75    /// The Postgres schema whose `outbox_events` table [`stage`](Self::stage) writes to, as the
76    /// hooks named it ([`WriteHooks::outbox_schema`]).
77    pub outbox_schema: Option<String>,
78}
79
80impl WriteCtx {
81    /// The context of a write made now, in the current task.
82    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    /// The company to stage an event under when the entity names none: the request's, else the
101    /// nil uuid. The outbox table requires a value, and under org-unit tenancy the fence is the
102    /// org unit the table fills from the acting unit, not this column.
103    pub fn company_or_nil(&self) -> Uuid {
104        self.company_id.unwrap_or_else(Uuid::nil)
105    }
106
107    /// The company an event of `row` is staged under: the row's own `company_id`, else the
108    /// request's, else the nil uuid.
109    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    /// Stage `record` on the write's transaction. It commits with the write and vanishes if
117    /// the write rolls back. The record takes this context's correlation id (and causation id)
118    /// unless it already carries one.
119    ///
120    /// # Errors
121    /// A [`ServiceError`] when the hooks named no outbox schema, when no write transaction is
122    /// active (the repository has no pool, so the service ran the write without one), or when
123    /// the insert fails — which rolls the write back, since a write whose event cannot be staged
124    /// must not commit.
125    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
146/// A fact the module declares it publishes (`events:` in a schema file), as the generated typed
147/// struct of its payload.
148///
149/// The schema generator implements this for every declared event, in the module's
150/// `domain::event::declared`. An event with an `on:` trigger is staged by the generated write
151/// hooks; one without is staged by hand code, which builds its row with [`record`](Self::record)
152/// and stages it in its own transaction (`backbone_outbox::outbox::stage`) or on the write's
153/// ([`WriteCtx::stage`]).
154pub trait DeclaredEvent: serde::Serialize {
155    /// The event type name the outbox row carries (`TicketOpened`).
156    const NAME: &'static str;
157    /// The declared schema version of the payload.
158    const VERSION: i32;
159    /// The aggregate the event belongs to (`Ticket`), or `""` when the declaration names none.
160    const AGGREGATE: &'static str;
161
162    /// The outbox row of this event for aggregate `aggregate_id`, under the company of `ctx`
163    /// (or the nil uuid when the request has none). A fresh event id, now as the time it
164    /// occurred, the declared version, and the payload serialized as written.
165    fn record(&self, aggregate_id: &str, ctx: &WriteCtx) -> OutboxRecord {
166        self.record_for(aggregate_id, ctx.company_or_nil(), ctx)
167    }
168
169    /// [`record`](Self::record) for an aggregate that carries its own company.
170    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
189/// The payload of a declared event, read out of the row the write stored: the fields listed,
190/// by their serialized names, deserialized into the event's struct. A listed field the row
191/// lacks is left out, so an optional one reads as `None` and a required one is an error — the
192/// generator only lists fields the aggregate has.
193pub 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
212/// Create the outbox and inbox tables of `schema` (idempotent), where the events its hooks stage
213/// are written. The composing service runs it at startup, with a role that may create tables;
214/// the generated module offers it for each schema it stages in (`declared::migrate_outbox`).
215pub 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/// The kind of a [`Change`], without its rows.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum ChangeKind {
223    /// A new row.
224    Created,
225    /// An existing row changed by a generic write.
226    Updated,
227    /// An existing row moved by a state-machine verb.
228    Transitioned,
229    /// A row deleted (soft, or for good).
230    Deleted,
231    /// A soft-deleted row restored.
232    Restored,
233}
234
235/// One row change, as a write hook sees it.
236///
237/// In [`WriteHooks::before`] the row to be stored is mutable: a hook may fill a derived field.
238/// In [`WriteHooks::after`] it is the row as stored. Matching on it needs a wildcard arm; kinds
239/// may be added.
240#[derive(Debug)]
241#[non_exhaustive]
242pub enum Change<'a, E> {
243    /// A row is created.
244    Created {
245        /// The row to store, or as stored.
246        after: &'a mut E,
247    },
248    /// A row is changed by a generic write (PUT, PATCH or their bulk forms).
249    Updated {
250        /// The row as it was read.
251        before: &'a E,
252        /// The row to store, or as stored.
253        after: &'a mut E,
254    },
255    /// A row is moved by a state-machine verb.
256    Transitioned {
257        /// The row as it was read.
258        before: &'a E,
259        /// The row to store, or as stored.
260        after: &'a mut E,
261        /// The verb (`close`, `approve`).
262        transition: String,
263        /// The state field that moved, in the serialized naming (`status`).
264        field: String,
265        /// The state the row left.
266        from: Option<String>,
267        /// The state the row entered.
268        to: Option<String>,
269    },
270    /// A row is deleted: soft-deleted, or removed for good when `hard`.
271    Deleted {
272        /// The row as it was read.
273        before: &'a E,
274        /// Whether the row is removed for good (from the trash) rather than moved to it.
275        hard: bool,
276    },
277    /// A soft-deleted row is restored.
278    Restored {
279        /// The row: as it was in the trash before the write, as restored after it.
280        entity: &'a E,
281    },
282}
283
284impl<'a, E> Change<'a, E> {
285    /// Which kind of change this is.
286    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    /// The row as it was read before the write; `None` for a create.
297    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    /// The row the write stores; `None` for a delete.
307    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    /// The row an event of this change is about: what the write stores, or the row deleted.
318    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    /// The row to store, mutable: for a hook that fills a field before the write. `None` for a
330    /// delete or a restore, which store no new row.
331    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    /// The row as it was read and the row to store, the second mutable: for a hook that fills a
341    /// field of an update from what it was. `None` unless the change is an update.
342    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    /// Whether the serialized field `name` differs between the row before and after. A create
354    /// changes every field it sets (non-null); a delete changes none.
355    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    /// The change an update writes: a [`Change::Transitioned`] when a state-machine verb performs
370    /// it ([`as_transition`](crate::write_guard::as_transition)), else a [`Change::Updated`].
371    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        // The state field the verb moved: the first whose value differs, else the entity's first.
384        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            // A verb on an entity with no state field: nothing to say about a move.
399            None => Change::Updated { before, after },
400        }
401    }
402}
403
404// ── Hook triggers ───────────────────────────────────────────────────────────────
405
406/// How many trigger runs may be nested: a trigger whose hand method writes another entity runs that
407/// entity's triggers inside its own, and so on. The run past this depth is refused, so a cycle of
408/// triggers ends in an error instead of a stack.
409pub const TRIGGER_MAX_DEPTH: u32 = 8;
410
411/// The violation code of a trigger run refused for nesting too deep.
412pub const TRIGGER_DEPTH_EXCEEDED: &str = "trigger_depth_exceeded";
413
414tokio::task_local! {
415    static TRIGGER_DEPTH: u32;
416}
417
418/// How many trigger runs enclose the current task.
419pub fn current_trigger_depth() -> u32 {
420    TRIGGER_DEPTH.try_with(|d| *d).unwrap_or(0)
421}
422
423/// Run `f`, the triggers of one hook, one level deeper than the run that encloses it. The generated
424/// write hooks wrap every trigger they run in this, so writes a trigger makes through other
425/// entities' services (whose hooks wrap theirs the same way) count their nesting: the
426/// [`TRIGGER_MAX_DEPTH`]th level inside another is refused with the violation
427/// [`TRIGGER_DEPTH_EXCEEDED`], which rolls the whole write back.
428pub 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/// A scheduled entry of a model's hook triggers (`schedule:` on a trigger): what a composing
445/// service's scheduler runs at `schedule`, with the generated `run_<model>_scheduled`.
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub struct ScheduledTrigger {
448    /// The entry's name (its key in the hook file).
449    pub name: &'static str,
450    /// The schedule as written: cron, `every 5.minutes`, `every 1.day at 03:00`.
451    pub schedule: &'static str,
452    /// What the entry says it is for.
453    pub description: &'static str,
454}
455
456/// A request to tell someone something, staged by a hook trigger's `send_email` or `notify`. An
457/// effect outside the database is never performed inside the write's transaction: the request is an
458/// outbox row (`NotificationRequested`) that commits with the write, and a consumer (a mail or
459/// notification module's relay subscriber) performs it, at least once and idempotently.
460///
461/// The payload is this struct as JSON. `channel` is `email` or `notify`; a `send_email` names a
462/// `template` and a `notify` a `template` or a literal `body`; `to` is what the trigger's recipient
463/// expression evaluated to (an address, an id, or a list of them); `data` is the template's
464/// variables; `trigger` is `<Model>.<key>` of the trigger that asked.
465#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
466pub struct NotificationRequested {
467    /// `email` or `notify`.
468    pub channel: String,
469    /// The template to render, when the request names one.
470    pub template: Option<String>,
471    /// The literal text of a `notify`, with its `#{field}` already filled in.
472    pub body: Option<String>,
473    /// The recipients, as evaluated.
474    pub to: Value,
475    /// The template variables, as evaluated.
476    pub data: Value,
477    /// The trigger that asked: `<Model>.<key>`.
478    pub trigger: String,
479}
480
481impl NotificationRequested {
482    /// The event type of the outbox row.
483    pub const NAME: &'static str = "NotificationRequested";
484    /// The payload version.
485    pub const VERSION: i32 = 1;
486
487    /// The outbox row of this request, about `aggregate` (`User`) `aggregate_id`, under `company_id`
488    /// (see [`WriteCtx::company_of`]), carrying the write's correlation and causation ids.
489    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/// Code that runs inside a write's transaction, before and after the repository write.
514///
515/// Both methods default to doing nothing. An error from either rolls the write back and answers
516/// as the service answers any failed write.
517#[async_trait]
518pub trait WriteHooks<E: PersistentEntity>: Send + Sync {
519    /// The Postgres schema whose `outbox_events` table [`WriteCtx::stage`] writes to. `None`
520    /// (the default) means these hooks stage nothing.
521    fn outbox_schema(&self) -> Option<&str> {
522        None
523    }
524
525    /// Before the repository writes `change`: may refuse it, and may fill fields of the row to
526    /// be stored. Runs after the guard and [`ServiceLifecycle::before_*`](crate::service::ServiceLifecycle).
527    async fn before(&self, ctx: &WriteCtx, change: &mut Change<'_, E>) -> ServiceResult<()> {
528        let _ = (ctx, change);
529        Ok(())
530    }
531
532    /// After the repository wrote `change`, still inside the transaction: the place to stage
533    /// events and to write rows that must commit with it. Runs after
534    /// [`ServiceLifecycle::after_*`](crate::service::ServiceLifecycle).
535    async fn after(&self, ctx: &WriteCtx, change: &Change<'_, E>) -> ServiceResult<()> {
536        let _ = (ctx, change);
537        Ok(())
538    }
539}
540
541/// The hooks of a service that has none: every write runs in its transaction and nothing more.
542#[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        // A create sets what it sets: a null field is not a change, a filled one is.
661        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        // Each run is one level inside the last; the ninth level inside another is refused.
712        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        // The refusal itself: a violation with the code and the cap.
742        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        // An optional field the row lacks reads as none; a required one is an error.
862        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        // An aggregate that carries its own company is staged under it.
900        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}