//! `ServiceEventEmitter` — the contract every stateful SDK service uses //! to publish events into the (future) triggers framework. //! //! v1.1.0 ships only the trait shape and a `NoopEventEmitter` that //! drops every event. The real outbox-backed implementation lands with //! the triggers PR in v1.1.1; locking the trait now means services //! written in subsequent v1.1.x PRs (KV, docs, files, …) don't have to //! re-thread their plumbing when the dispatcher arrives. //! //! Design rationale (full discussion: `docs/sdk-shape.md`): //! * Async — outbox writes hit Postgres. //! * Cx is passed in so the emitter can attribute the event to the //! `app_id` / `principal` / `execution_id` that produced it. //! * Events carry their semantic identity (`source` + `op`) plus //! optional locator (`collection` + `key`) and optional payloads //! (`payload` for the new value, `old_payload` for the previous on //! updates). The dispatcher matches on (source, op, collection) //! filters to decide which scripts to fan out to. use async_trait::async_trait; use thiserror::Error; use crate::SdkCallCx; /// Trait every stateful service depends on to emit events. The host /// binary constructs one instance and clones the Arc into each service. #[async_trait] pub trait ServiceEventEmitter: Send + Sync { /// Publish a single event. Implementations are expected to be /// fire-and-forget from the caller's perspective: the outbox impl /// will return `Ok(())` once the event is durably persisted, the /// dispatcher reads it out-of-band. async fn emit(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError>; } /// One service event. `source` and `op` are `&'static str` because they /// come from a fixed enumeration baked into each service (`"kv"` + /// `"insert"`/`"update"`/`"delete"`, etc.) — never from user data. /// `collection`/`key`/payloads come from user data and are owned. #[derive(Debug, Clone)] pub struct ServiceEvent { /// Service namespace. Matches the Rhai module name: `"kv"`, /// `"docs"`, `"files"`, etc. pub source: &'static str, /// Operation verb. Each service defines its own vocabulary; /// dispatcher filters match on the literal string. pub op: &'static str, /// Affected collection, when the service is collection-scoped /// (`kv`, `docs`, `files`). `None` for collection-less events. pub collection: Option, /// Affected key/id within the collection, when applicable. pub key: Option, /// New value after the operation, when carrying it is cheap and /// useful. `None` for deletes. pub payload: Option, /// Previous value before the operation, populated on `update` / /// `delete` so triggers can diff. `None` on `insert`. pub old_payload: Option, } /// Errors an emitter can surface upward. The noop impl never returns /// these; the v1.1.1 outbox impl uses `Unavailable` for pool/connection /// failures and `Rejected` for malformed payloads (e.g. event JSON too /// large for the outbox row). #[derive(Debug, Error)] pub enum EmitError { #[error("event sink unavailable: {0}")] Unavailable(String), #[error("event sink rejected event: {0}")] Rejected(String), } /// Default emitter for v1.1.0. Accepts every event, persists nothing, /// always returns `Ok(())`. Wired in the picloud binary; the v1.1.1 /// triggers PR swaps this for a Postgres outbox writer. #[derive(Debug, Default, Clone, Copy)] pub struct NoopEventEmitter; #[async_trait] impl ServiceEventEmitter for NoopEventEmitter { async fn emit(&self, _cx: &SdkCallCx, _event: ServiceEvent) -> Result<(), EmitError> { Ok(()) } } #[cfg(test)] mod tests { use super::*; // Compile-time check that ServiceEventEmitter is dyn-safe — every // service holds it as `Arc` and would // silently break the workspace if a non-object-safe method snuck // in. Behavioural tests for the noop impl come for free once a // service exercises it (v1.1.1+); avoid pulling tokio into // `picloud-shared` just for a one-line `emit().await` check. #[allow(dead_code)] fn assert_dyn_compatible(_e: &dyn ServiceEventEmitter) {} #[test] fn service_event_construction_is_explicit() { // Pin the field layout so a re-ordering in a future PR causes a // compile failure here rather than silently misattributing // events. Hash-derive isn't appropriate (serde_json::Value isn't // Hash), so structural construction is the assertion. let _ = ServiceEvent { source: "kv", op: "insert", collection: Some("widgets".into()), key: Some("k1".into()), payload: Some(serde_json::json!({"v": 1})), old_payload: None, }; } }