Skip to main content

architect_sdk/events/
mod.rs

1//! Decision-hub event publishing. Active only when DECISION_HUB_URL env var is set.
2//!
3//! After a successful CRUD operation the handler calls `spawn_events()` which
4//! evaluates configured triggers against the saved row and fires matching events
5//! to the decision-hub `/evaluate` endpoint inside a detached tokio task — the
6//! HTTP response is already on the wire before the publish begins.
7//!
8//! Event type format: `{package_id}.{table_name}:{event_name}`
9//! Example: `manufacturing_core.materials:published`
10
11use crate::config::resolved::ResolvedEntity;
12use crate::config::types::{EntityEventTrigger, EventCondition};
13use serde_json::Value;
14use std::sync::Arc;
15
16pub struct DecisionHubClient {
17    base_url: String,
18    client: reqwest::Client,
19}
20
21impl DecisionHubClient {
22    pub fn from_env() -> Option<Arc<Self>> {
23        let base_url = std::env::var("DECISION_HUB_URL").ok()?;
24        let timeout_secs: u64 = std::env::var("DECISION_HUB_TIMEOUT_SECS")
25            .ok()
26            .and_then(|s| s.parse().ok())
27            .unwrap_or(5);
28        let client = reqwest::Client::builder()
29            .timeout(std::time::Duration::from_secs(timeout_secs))
30            .build()
31            .ok()?;
32        tracing::info!(url = %base_url, "decision-hub event publishing enabled");
33        Some(Arc::new(Self { base_url, client }))
34    }
35
36    async fn publish(&self, tenant_id: &str, event_type: &str, context: Value) {
37        let payload = serde_json::json!({
38            "tenant_id": tenant_id,
39            "event_type": event_type,
40            "context": context,
41        });
42        let url = format!("{}/evaluate", self.base_url);
43        log_curl(&url, &payload);
44        match self.client.post(&url).json(&payload).send().await {
45            Ok(resp) if !resp.status().is_success() => {
46                let status = resp.status().as_u16();
47                let body = resp.text().await.unwrap_or_default();
48                tracing::warn!(
49                    event_type = %event_type,
50                    status = %status,
51                    body = %body,
52                    "decision-hub rejected event"
53                );
54            }
55            Err(e) => {
56                tracing::warn!(event_type = %event_type, error = %e, "decision-hub publish failed");
57            }
58            Ok(resp) => {
59                // /evaluate answers 200 with {request_id, executions, matched} even when nothing
60                // matched — log the body so a silent no-op is visible without a DB query.
61                let body = resp.text().await.unwrap_or_default();
62                tracing::info!(
63                    event_type = %event_type,
64                    response = %body,
65                    "decision-hub event accepted"
66                );
67            }
68        }
69    }
70}
71
72/// Emit the outbound request as a replayable curl. Off by default: set
73/// `DECISION_HUB_LOG_CURL=1` to log it at info, or enable
74/// `RUST_LOG=architect_sdk::events=debug` to get it at debug.
75///
76/// The payload has already had `sensitive_columns` stripped, but it still carries full row data —
77/// keep this off in production unless you are actively debugging.
78fn log_curl(url: &str, payload: &Value) {
79    let force = std::env::var("DECISION_HUB_LOG_CURL")
80        .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE"))
81        .unwrap_or(false);
82    if !force && !tracing::enabled!(tracing::Level::DEBUG) {
83        return;
84    }
85    let body = serde_json::to_string(payload).unwrap_or_default();
86    // Single-quoted shell literal: the only byte needing care is `'` itself.
87    let curl = format!(
88        "curl -sS -X POST '{}' -H 'Content-Type: application/json' --data-raw '{}'",
89        url,
90        body.replace('\'', r#"'\''"#),
91    );
92    if force {
93        tracing::info!(curl = %curl, "decision-hub request");
94    } else {
95        tracing::debug!(curl = %curl, "decision-hub request");
96    }
97}
98
99/// Returns true when the trigger's condition is satisfied.
100///
101/// `row` is the post-operation snake_case row (new state).
102/// `pre_update_row` is the row fetched from DB *before* the update — only supplied for the
103/// "update" lifecycle when the entity has `changed_to` conditions. When present, `changed_to`
104/// requires a genuine transition: the field must have been a different value before the update.
105fn evaluate_condition(
106    condition: &EventCondition,
107    row: &Value,
108    pre_update_row: Option<&Value>,
109) -> bool {
110    let new_val = row.get(&condition.field);
111    if let Some(target) = &condition.changed_to {
112        let now_matches = new_val == Some(target);
113        return match pre_update_row {
114            // With old state: require old ≠ target AND new == target (real transition).
115            Some(old_row) => now_matches && old_row.get(&condition.field) != Some(target),
116            // Without old state: fall back to checking the new value only.
117            None => now_matches,
118        };
119    }
120    if let Some(target) = &condition.equals {
121        return new_val == Some(target);
122    }
123    if let Some(not_null) = condition.not_null {
124        let is_not_null = matches!(new_val, Some(v) if !v.is_null());
125        return is_not_null == not_null;
126    }
127    true
128}
129
130fn default_event_name(on: &str) -> &str {
131    match on {
132        "create" => "created",
133        "update" => "updated",
134        "delete" => "deleted",
135        "archive" => "archived",
136        other => other,
137    }
138}
139
140/// Check whether a trigger matches the current lifecycle + row state.
141fn trigger_matches(
142    trigger: &EntityEventTrigger,
143    lifecycle: &str,
144    raw_row: &Value,
145    archive_field: Option<&str>,
146    pre_update_row: Option<&Value>,
147) -> bool {
148    match trigger.on.as_str() {
149        on if on == lifecycle => {
150            if let Some(cond) = &trigger.condition {
151                evaluate_condition(cond, raw_row, pre_update_row)
152            } else {
153                true
154            }
155        }
156        // "archive" triggers fire during an update when archive_field transitions to non-null.
157        "archive" if lifecycle == "update" => archive_field
158            .and_then(|f| raw_row.get(f))
159            .map(|v| !v.is_null())
160            .unwrap_or(false),
161        _ => false,
162    }
163}
164
165/// Spawn a background task that publishes matching event triggers to decision-hub.
166///
167/// - `lifecycle`: `"create"` | `"update"` | `"delete"`
168/// - `raw_row`: snake_case row used for condition evaluation (post-operation state)
169/// - `api_row`: camelCase row sent as the event context (sensitive columns already stripped)
170/// - `pre_update_row`: snake_case row fetched from DB *before* the update. Used both to detect
171///   genuine `changed_to` transitions and, for the "update" lifecycle, to emit a `previous`
172///   snapshot in the event context (sensitive columns stripped, camelCased — same shape as
173///   `entity`). Pass `Some` for updates to get accurate transitions and old→new deltas; `None`
174///   for create/delete or when no pre-read was performed.
175///
176/// Returns immediately; the HTTP publish happens after the response is sent.
177pub fn spawn_events(
178    client: Arc<DecisionHubClient>,
179    entity: &ResolvedEntity,
180    lifecycle: &'static str,
181    raw_row: Value,
182    api_row: Value,
183    tenant_id: String,
184    pre_update_row: Option<Value>,
185) {
186    spawn_events_with(
187        client,
188        entity,
189        lifecycle,
190        raw_row,
191        api_row,
192        tenant_id,
193        pre_update_row,
194        None,
195    );
196}
197
198/// Everything the publish task needs to re-read the row with its related entities expanded.
199///
200/// Owned rather than borrowed: the fetch happens inside the detached task, after the handler's
201/// executor (and any RLS transaction) is gone.
202#[derive(Clone)]
203pub struct EventIncludeCtx {
204    pub pool: crate::db::pool::Pool,
205    /// `Some(tenant)` for RLS-strategy tenants — a fresh transaction with `SET LOCAL` is opened
206    /// for the fetch. `None` for database-strategy tenants.
207    pub rls_tenant: Option<String>,
208    pub schema_override: Option<String>,
209    pub dialect: Arc<dyn crate::db::Dialect>,
210    pub entity: ResolvedEntity,
211    /// Every include named by any trigger on this entity, already resolved. Each trigger picks its
212    /// own subset by name.
213    pub resolved: Vec<(String, crate::config::IncludeSpec, ResolvedEntity)>,
214    pub pk_column: String,
215    /// Primary-key value of the affected row, rendered for an RSQL `==` leaf.
216    pub pk_value: String,
217}
218
219impl EventIncludeCtx {
220    /// Same resolution pointed at a different row. Bulk paths publish one event per row but
221    /// resolve the include set once for the whole batch — only the pk differs.
222    pub fn with_pk_value(&self, pk_value: String) -> Self {
223        Self {
224            pk_value,
225            ..self.clone()
226        }
227    }
228}
229
230/// As [`spawn_events`], plus the context needed to honour each trigger's `include` list.
231#[allow(clippy::too_many_arguments)]
232pub fn spawn_events_with(
233    client: Arc<DecisionHubClient>,
234    entity: &ResolvedEntity,
235    lifecycle: &'static str,
236    raw_row: Value,
237    api_row: Value,
238    tenant_id: String,
239    pre_update_row: Option<Value>,
240    include_ctx: Option<EventIncludeCtx>,
241) {
242    if entity.events.is_empty() {
243        return;
244    }
245
246    let triggers: Vec<EntityEventTrigger> = entity
247        .events
248        .iter()
249        .filter(|t| {
250            trigger_matches(
251                t,
252                lifecycle,
253                &raw_row,
254                entity.archive_field.as_deref(),
255                pre_update_row.as_ref(),
256            )
257        })
258        .cloned()
259        .collect();
260
261    if triggers.is_empty() {
262        return;
263    }
264
265    let package_id = entity.package_id.clone();
266    let table_name = entity.table_name.clone();
267    let sensitive_columns = entity.sensitive_columns.clone();
268
269    // Old→new deltas: only the "update" lifecycle carries a `previous` snapshot, and only when the
270    // handler supplied the pre-update row. Process it exactly like `entity` (sensitive columns
271    // stripped, keys camelCased) so both sides of the delta are shaped identically.
272    let previous = match (lifecycle, pre_update_row) {
273        ("update", Some(mut old)) => {
274            crate::handlers::entity::strip_sensitive_columns(&mut old, &sensitive_columns);
275            crate::case::value_keys_to_camel_case(&mut old);
276            Some(old)
277        }
278        _ => None,
279    };
280
281    tokio::spawn(async move {
282        // Cache expansions across triggers: several triggers on one entity usually name the same
283        // includes, and a delete has no row left to read.
284        let mut expanded: std::collections::HashMap<String, Value> =
285            std::collections::HashMap::new();
286
287        for trigger in &triggers {
288            let suffix = trigger
289                .event_name
290                .as_deref()
291                .unwrap_or_else(|| default_event_name(trigger.on.as_str()));
292            let event_type = format!("{}.{}:{}", package_id, table_name, suffix);
293            tracing::info!(
294                tenant_id = %tenant_id,
295                event_type = %event_type,
296                lifecycle = %lifecycle,
297                "publishing decision-hub event"
298            );
299
300            let entity_value = match (&include_ctx, trigger.include.is_empty(), lifecycle) {
301                // Nothing requested, no context wired up, or the row is already gone.
302                (_, true, _) | (None, _, _) | (_, _, "delete") => api_row.clone(),
303                (Some(ctx), false, _) => {
304                    let mut names = trigger.include.clone();
305                    names.sort();
306                    names.dedup();
307                    let key = names.join(",");
308                    match expanded.get(&key) {
309                        Some(v) => v.clone(),
310                        None => {
311                            let v = fetch_with_includes(ctx, &names)
312                                .await
313                                .unwrap_or_else(|| api_row.clone());
314                            expanded.insert(key, v.clone());
315                            v
316                        }
317                    }
318                }
319            };
320
321            let mut context = serde_json::json!({
322                "entity": entity_value,
323                "operation": lifecycle,
324            });
325            // Present only on updates; carries the pre-update state for old→new comparison.
326            if let Some(prev) = &previous {
327                context["previous"] = prev.clone();
328            }
329            client.publish(&tenant_id, &event_type, context).await;
330        }
331    });
332}
333
334/// Re-read the affected row with `names` expanded. Returns `None` on any failure — the caller
335/// falls back to the flat row, so a broken include never costs the event itself.
336async fn fetch_with_includes(ctx: &EventIncludeCtx, names: &[String]) -> Option<Value> {
337    use crate::service::CrudService;
338    use crate::sql::{FilterNode, IncludeSelect, RsqlOp};
339
340    let selected: Vec<&(String, crate::config::IncludeSpec, ResolvedEntity)> = ctx
341        .resolved
342        .iter()
343        .filter(|(name, _, _)| names.iter().any(|n| n == name))
344        .collect();
345
346    for want in names {
347        if !selected.iter().any(|(name, _, _)| name == want) {
348            tracing::warn!(
349                entity = %ctx.entity.path_segment,
350                include = %want,
351                "event include is not a configured relationship — skipped"
352            );
353        }
354    }
355    if selected.is_empty() {
356        return None;
357    }
358
359    let include_selects: Vec<IncludeSelect> = selected
360        .iter()
361        .map(|(name, spec, related)| IncludeSelect {
362            name: name.as_str(),
363            direction: spec.direction.clone(),
364            related,
365            our_key: spec.our_key_column.as_str(),
366            their_key: spec.their_key_column.as_str(),
367        })
368        .collect();
369
370    let filter = FilterNode::Leaf {
371        field: ctx.pk_column.clone(),
372        op: RsqlOp::Eq,
373        values: vec![ctx.pk_value.clone()],
374    };
375
376    // RLS tenants need their own transaction here: the handler's has already been committed.
377    let mut rls_tx = match &ctx.rls_tenant {
378        Some(tenant) => {
379            let mut tx = ctx.pool.begin().await.ok()?;
380            if let Some(sql) = ctx.dialect.set_tenant_session_sql(tenant) {
381                sqlx::query(&sql).execute(&mut *tx).await.ok()?;
382            }
383            Some(tx)
384        }
385        None => None,
386    };
387    let mut executor = match rls_tx.as_mut() {
388        Some(tx) => crate::service::TenantExecutor::conn(tx, ctx.dialect.as_ref()),
389        None => crate::service::TenantExecutor::pool(&ctx.pool, ctx.dialect.as_ref()),
390    };
391
392    let rows = CrudService::list_with_includes(
393        &mut executor,
394        &ctx.entity,
395        Some(&filter),
396        &[],
397        Some(1),
398        None,
399        include_selects.as_slice(),
400        &[],
401        ctx.schema_override.as_deref(),
402        ctx.dialect.as_ref(),
403        None,
404    )
405    .await;
406
407    let mut rows = match rows {
408        Ok(r) => r,
409        Err(e) => {
410            tracing::warn!(
411                entity = %ctx.entity.path_segment,
412                error = %e,
413                "event include fetch failed — publishing the flat row"
414            );
415            return None;
416        }
417    };
418
419    let owned: Vec<(String, crate::config::IncludeSpec, ResolvedEntity)> =
420        selected.into_iter().cloned().collect();
421    crate::handlers::entity::post_process_include_columns(&mut rows, &owned);
422
423    let mut row = rows.into_iter().next()?;
424    crate::handlers::entity::strip_sensitive_columns(&mut row, &ctx.entity.sensitive_columns);
425    for (name, _, related) in &owned {
426        // Related rows carry their own sensitive-column list; they are keyed by include name.
427        if let Some(nested) = row.get_mut(name) {
428            strip_nested_sensitive(nested, &related.sensitive_columns);
429        }
430    }
431    crate::case::value_keys_to_camel_case(&mut row);
432    Some(row)
433}
434
435fn strip_nested_sensitive(v: &mut Value, sensitive: &std::collections::HashSet<String>) {
436    match v {
437        Value::Array(items) => {
438            for item in items {
439                crate::handlers::entity::strip_sensitive_columns(item, sensitive);
440            }
441        }
442        Value::Object(_) => crate::handlers::entity::strip_sensitive_columns(v, sensitive),
443        _ => {}
444    }
445}