//! `OutboxEventEmitter` — the real `ServiceEventEmitter` that replaces //! v1.1.0's `NoopEventEmitter` once the triggers framework lands. //! //! On each `emit` (a KV mutation, future doc/file/pubsub event, etc.): //! 1. Look up matching triggers for the event's (app_id, source, op, //! collection) tuple via `TriggerRepo::list_matching_*`. //! 2. For each match, write one outbox row carrying the event payload //! serialized as a `TriggerEvent`. //! //! Defaults applied at write time so `OutboxRow.payload` carries //! everything the dispatcher needs to reconstruct the executor //! invocation without joining back to the trigger row. //! //! Non-KV `ServiceEvent` sources are silently dropped in v1.1.1 — the //! dispatcher only knows how to fire KV triggers this release. Future //! sources (docs/files/pubsub) add their own dispatch arm. use std::sync::Arc; use async_trait::async_trait; use picloud_shared::{ DocsEventOp, EmitError, FileMeta, FilesEventOp, GroupId, KvEventOp, SdkCallCx, ServiceEvent, ServiceEventEmitter, TriggerEvent, }; use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind}; use crate::trigger_repo::TriggerRepo; pub struct OutboxEventEmitter { triggers: Arc, outbox: Arc, } impl OutboxEventEmitter { #[must_use] pub fn new(triggers: Arc, outbox: Arc) -> Self { Self { triggers, outbox } } } #[async_trait] impl ServiceEventEmitter for OutboxEventEmitter { async fn emit(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> { match event.source { "kv" => self.emit_kv(cx, event).await, "docs" => self.emit_docs(cx, event).await, "files" => self.emit_files(cx, event).await, // Future sources land here. For now, silently drop — the // SDK calls `events.emit(...)` unconditionally for forward // compat, so swallowing without an error is correct. _ => Ok(()), } } #[allow(clippy::too_many_lines)] // one match arm per source kind (kv/docs/files) async fn emit_shared( &self, cx: &SdkCallCx, owning_group: GroupId, event: ServiceEvent, ) -> Result<(), EmitError> { // §11.6: a shared-collection write. Match `shared = true` triggers on // the OWNING group; each fires under the WRITER app (`cx.app_id`), the // same "group template runs under the firing app" model. let source_kind = match event.source { "kv" => OutboxSourceKind::Kv, "docs" => OutboxSourceKind::Docs, "files" => OutboxSourceKind::Files, _ => return Ok(()), }; let Some(collection) = event.collection.clone() else { return Ok(()); }; let (matches, trigger_event) = match event.source { "kv" => { let Some(op) = KvEventOp::from_wire(event.op) else { return Ok(()); }; let m = self .triggers .list_matching_shared_kv(owning_group, &collection, op) .await .map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?; let ev = TriggerEvent::Kv { op, collection, key: event.key.clone().unwrap_or_default(), value: event.payload.clone(), }; ( m.into_iter() .map(|x| (x.trigger_id, x.script_id)) .collect::>(), ev, ) } "docs" => { let Some(op) = DocsEventOp::from_wire(event.op) else { return Ok(()); }; let m = self .triggers .list_matching_shared_docs(owning_group, &collection, op) .await .map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?; let ev = TriggerEvent::Docs { op, collection, id: event.key.clone().unwrap_or_default(), data: event.payload.clone(), prev_data: event.old_payload.clone(), }; ( m.into_iter() .map(|x| (x.trigger_id, x.script_id)) .collect::>(), ev, ) } "files" => { let Some(op) = FilesEventOp::from_wire(event.op) else { return Ok(()); }; let Some(meta) = event .payload .clone() .and_then(|v| serde_json::from_value::(v).ok()) else { return Ok(()); }; let m = self .triggers .list_matching_shared_files(owning_group, &collection, op) .await .map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?; let ev = TriggerEvent::Files { op, collection, id: meta.id.to_string(), name: meta.name, content_type: meta.content_type, size: meta.size, checksum: meta.checksum, prev: event.old_payload.clone(), }; ( m.into_iter() .map(|x| (x.trigger_id, x.script_id)) .collect::>(), ev, ) } _ => return Ok(()), }; if matches.is_empty() { return Ok(()); } let payload = serde_json::to_value(&trigger_event) .map_err(|e| EmitError::Rejected(format!("event serialize: {e}")))?; for (trigger_id, script_id) in matches { self.outbox .insert(NewOutboxRow { app_id: cx.app_id, source_kind, trigger_id: Some(trigger_id), script_id: Some(script_id), reply_to: None, payload: payload.clone(), origin_principal: cx.principal.as_ref().map(|p| p.user_id), trigger_depth: cx.trigger_depth.saturating_add(1), root_execution_id: Some(cx.root_execution_id), }) .await .map_err(|e| EmitError::Unavailable(format!("outbox insert: {e}")))?; } Ok(()) } } impl OutboxEventEmitter { async fn emit_kv(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> { let Some(op) = KvEventOp::from_wire(event.op) else { return Ok(()); // unknown op — drop quietly }; let Some(collection) = event.collection.clone() else { return Ok(()); // KV events always carry a collection — defensively skip }; let key = event.key.clone().unwrap_or_default(); let matches = self .triggers .list_matching_kv(cx.app_id, &collection, op) .await .map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?; if matches.is_empty() { return Ok(()); } // Serialize the originating event as a TriggerEvent so the // dispatcher can hand it to the script as `ctx.event` without // round-tripping back to the trigger row. let trigger_event = TriggerEvent::Kv { op, collection, key, value: event.payload.clone(), }; let payload = serde_json::to_value(&trigger_event) .map_err(|e| EmitError::Rejected(format!("event serialize: {e}")))?; for m in matches { self.outbox .insert(NewOutboxRow { app_id: cx.app_id, source_kind: OutboxSourceKind::Kv, trigger_id: Some(m.trigger_id), script_id: Some(m.script_id), reply_to: None, payload: payload.clone(), origin_principal: cx.principal.as_ref().map(|p| p.user_id), trigger_depth: cx.trigger_depth.saturating_add(1), root_execution_id: Some(cx.root_execution_id), }) .await .map_err(|e| EmitError::Unavailable(format!("outbox insert: {e}")))?; } Ok(()) } /// v1.1.2. Mirrors `emit_kv` — fan out a docs mutation across /// matching docs triggers + write one outbox row each. The /// `prev_data` change-data-capture surface is preserved from the /// `ServiceEvent.old_payload` field (set by `DocsServiceImpl` on /// update and delete; `None` for create). async fn emit_docs(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> { let Some(op) = DocsEventOp::from_wire(event.op) else { return Ok(()); }; let Some(collection) = event.collection.clone() else { return Ok(()); }; let id = event.key.clone().unwrap_or_default(); let matches = self .triggers .list_matching_docs(cx.app_id, &collection, op) .await .map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?; if matches.is_empty() { return Ok(()); } let trigger_event = TriggerEvent::Docs { op, collection, id, data: event.payload.clone(), prev_data: event.old_payload.clone(), }; let payload = serde_json::to_value(&trigger_event) .map_err(|e| EmitError::Rejected(format!("event serialize: {e}")))?; for m in matches { self.outbox .insert(NewOutboxRow { app_id: cx.app_id, source_kind: OutboxSourceKind::Docs, trigger_id: Some(m.trigger_id), script_id: Some(m.script_id), reply_to: None, payload: payload.clone(), origin_principal: cx.principal.as_ref().map(|p| p.user_id), trigger_depth: cx.trigger_depth.saturating_add(1), root_execution_id: Some(cx.root_execution_id), }) .await .map_err(|e| EmitError::Unavailable(format!("outbox insert: {e}")))?; } Ok(()) } /// v1.1.5. Fan out a files mutation across matching files triggers. /// The `ServiceEvent.payload` is the file **metadata** (never the /// blob bytes); `old_payload` is the prior metadata (the deleted /// row's metadata on delete). The `TriggerEvent::Files` carries the /// metadata fields explicitly + `prev` for the change-data-capture /// surface. async fn emit_files(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> { let Some(op) = FilesEventOp::from_wire(event.op) else { return Ok(()); }; let Some(collection) = event.collection.clone() else { return Ok(()); }; // The payload is the FileMeta JSON the FilesServiceImpl emitted. let Some(meta) = event .payload .clone() .and_then(|v| serde_json::from_value::(v).ok()) else { return Ok(()); }; let matches = self .triggers .list_matching_files(cx.app_id, &collection, op) .await .map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?; if matches.is_empty() { return Ok(()); } let trigger_event = TriggerEvent::Files { op, collection, id: meta.id.to_string(), name: meta.name, content_type: meta.content_type, size: meta.size, checksum: meta.checksum, prev: event.old_payload.clone(), }; let payload = serde_json::to_value(&trigger_event) .map_err(|e| EmitError::Rejected(format!("event serialize: {e}")))?; for m in matches { self.outbox .insert(NewOutboxRow { app_id: cx.app_id, source_kind: OutboxSourceKind::Files, trigger_id: Some(m.trigger_id), script_id: Some(m.script_id), reply_to: None, payload: payload.clone(), origin_principal: cx.principal.as_ref().map(|p| p.user_id), trigger_depth: cx.trigger_depth.saturating_add(1), root_execution_id: Some(cx.root_execution_id), }) .await .map_err(|e| EmitError::Unavailable(format!("outbox insert: {e}")))?; } Ok(()) } }