feat(shared-triggers): emit shared events from group services (M2.3)
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>
This commit is contained in:
@@ -19,7 +19,7 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
DocsEventOp, EmitError, FileMeta, FilesEventOp, KvEventOp, SdkCallCx, ServiceEvent,
|
||||
DocsEventOp, EmitError, FileMeta, FilesEventOp, GroupId, KvEventOp, SdkCallCx, ServiceEvent,
|
||||
ServiceEventEmitter, TriggerEvent,
|
||||
};
|
||||
|
||||
@@ -51,6 +51,130 @@ impl ServiceEventEmitter for OutboxEventEmitter {
|
||||
_ => 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 {
|
||||
|
||||
Reference in New Issue
Block a user