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:
@@ -11,7 +11,8 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
DocId, DocRow, DocsListPage, GroupDocsError, GroupDocsService, GroupId, SdkCallCx,
|
||||
DocId, DocRow, DocsListPage, GroupDocsError, GroupDocsService, GroupId, NoopEventEmitter,
|
||||
SdkCallCx, ServiceEvent, ServiceEventEmitter,
|
||||
};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
@@ -28,6 +29,8 @@ pub struct GroupDocsServiceImpl {
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_value_bytes: usize,
|
||||
/// §11.6: fires `shared = true` docs triggers on a shared-collection write.
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
}
|
||||
|
||||
impl GroupDocsServiceImpl {
|
||||
@@ -40,6 +43,13 @@ impl GroupDocsServiceImpl {
|
||||
Self::with_max_value_bytes(repo, resolver, authz, docs_max_value_bytes_from_env())
|
||||
}
|
||||
|
||||
/// Wire the event emitter (§11.6 shared-collection triggers).
|
||||
#[must_use]
|
||||
pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
|
||||
self.events = events;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_max_value_bytes(
|
||||
repo: Arc<dyn GroupDocsRepo>,
|
||||
@@ -48,6 +58,7 @@ impl GroupDocsServiceImpl {
|
||||
max_value_bytes: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
events: Arc::new(NoopEventEmitter),
|
||||
repo,
|
||||
resolver,
|
||||
authz,
|
||||
@@ -55,6 +66,39 @@ impl GroupDocsServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
/// §11.6: fire `shared = true` docs triggers on the owning group.
|
||||
/// Best-effort; an emit failure is logged, never surfaced.
|
||||
#[allow(clippy::too_many_arguments)] // op/collection/id + new + old payloads
|
||||
async fn emit_shared(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
group_id: GroupId,
|
||||
op: &'static str,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
payload: Option<serde_json::Value>,
|
||||
old_payload: Option<serde_json::Value>,
|
||||
) {
|
||||
if let Err(e) = self
|
||||
.events
|
||||
.emit_shared(
|
||||
cx,
|
||||
group_id,
|
||||
ServiceEvent {
|
||||
source: "docs",
|
||||
op,
|
||||
collection: Some(collection.to_string()),
|
||||
key: Some(id.to_string()),
|
||||
payload,
|
||||
old_payload,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %e, source = "docs", op, event_emit_failure = true, "shared event emit failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// The structural boundary: resolve the collection to its owning group
|
||||
/// (kind=`docs`) on the calling app's chain. `CollectionNotShared` when no
|
||||
/// ancestor group declares it.
|
||||
@@ -145,7 +189,18 @@ impl GroupDocsService for GroupDocsServiceImpl {
|
||||
validate_data(&data)?;
|
||||
self.check_data_size(&data)?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
Ok(self.repo.create(group_id, collection, data).await?.id)
|
||||
let created = self.repo.create(group_id, collection, data.clone()).await?;
|
||||
self.emit_shared(
|
||||
cx,
|
||||
group_id,
|
||||
"create",
|
||||
collection,
|
||||
&created.id.to_string(),
|
||||
Some(data),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
Ok(created.id)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
@@ -198,8 +253,24 @@ impl GroupDocsService for GroupDocsServiceImpl {
|
||||
validate_data(&data)?;
|
||||
self.check_data_size(&data)?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
match self.repo.update(group_id, collection, id, data).await? {
|
||||
Some(_) => Ok(()),
|
||||
match self
|
||||
.repo
|
||||
.update(group_id, collection, id, data.clone())
|
||||
.await?
|
||||
{
|
||||
Some(prev) => {
|
||||
self.emit_shared(
|
||||
cx,
|
||||
group_id,
|
||||
"update",
|
||||
collection,
|
||||
&id.to_string(),
|
||||
Some(data),
|
||||
Some(prev),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
None => Err(GroupDocsError::NotFound),
|
||||
}
|
||||
}
|
||||
@@ -212,7 +283,22 @@ impl GroupDocsService for GroupDocsServiceImpl {
|
||||
) -> Result<bool, GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
Ok(self.repo.delete(group_id, collection, id).await?.is_some())
|
||||
match self.repo.delete(group_id, collection, id).await? {
|
||||
Some(prev) => {
|
||||
self.emit_shared(
|
||||
cx,
|
||||
group_id,
|
||||
"delete",
|
||||
collection,
|
||||
&id.to_string(),
|
||||
None,
|
||||
Some(prev),
|
||||
)
|
||||
.await;
|
||||
Ok(true)
|
||||
}
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(
|
||||
|
||||
Reference in New Issue
Block a user