ServiceEventEmitter gains emit_shared(cx, owning_group, event) (default no-op); OutboxEventEmitter implements it — matches shared triggers on the owning group and writes outbox rows stamped with the WRITER app_id (dispatch runs under the writer). GroupKv/Docs/FilesServiceImpl gain an events field (Noop default + with_events builder) and emit on set/create/update/delete; the host wires the outbox emitter via with_events. Closes the "group trigger has no app to watch" gap. Authoring + validation land in M2.4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
348 lines
13 KiB
Rust
348 lines
13 KiB
Rust
//! `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<dyn TriggerRepo>,
|
|
outbox: Arc<dyn OutboxRepo>,
|
|
}
|
|
|
|
impl OutboxEventEmitter {
|
|
#[must_use]
|
|
pub fn new(triggers: Arc<dyn TriggerRepo>, outbox: Arc<dyn OutboxRepo>) -> 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::<Vec<_>>(),
|
|
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::<Vec<_>>(),
|
|
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::<FileMeta>(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::<Vec<_>>(),
|
|
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::<FileMeta>(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(())
|
|
}
|
|
}
|