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:
MechaCat02
2026-07-01 20:33:09 +02:00
parent cf296cd2fd
commit 0210ace406
6 changed files with 381 additions and 28 deletions

View File

@@ -12,7 +12,8 @@ use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
sanitize_stored_content_type, validate_files_collection, FileMeta, FileUpdate, FilesListPage,
GroupFilesError, GroupFilesService, GroupId, NewFile, SdkCallCx,
GroupFilesError, GroupFilesService, GroupId, NewFile, NoopEventEmitter, SdkCallCx,
ServiceEvent, ServiceEventEmitter,
};
use uuid::Uuid;
@@ -29,6 +30,8 @@ pub struct GroupFilesServiceImpl {
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
max_file_size_bytes: usize,
/// §11.6: fires `shared = true` files triggers on a shared-collection write.
events: Arc<dyn ServiceEventEmitter>,
}
impl GroupFilesServiceImpl {
@@ -44,6 +47,45 @@ impl GroupFilesServiceImpl {
resolver,
authz,
max_file_size_bytes,
events: Arc::new(NoopEventEmitter),
}
}
/// 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
}
/// §11.6: fire `shared = true` files triggers on the owning group.
/// Best-effort; an emit failure is logged, never surfaced.
async fn emit_shared(
&self,
cx: &SdkCallCx,
group_id: GroupId,
op: &'static str,
collection: &str,
meta: &FileMeta,
old: Option<&FileMeta>,
) {
if let Err(e) = self
.events
.emit_shared(
cx,
group_id,
ServiceEvent {
source: "files",
op,
collection: Some(collection.to_string()),
key: Some(meta.id.to_string()),
payload: serde_json::to_value(meta).ok(),
old_payload: old.and_then(|m| serde_json::to_value(m).ok()),
},
)
.await
{
tracing::error!(error = %e, source = "files", op, event_emit_failure = true, "shared event emit failed");
}
}
@@ -118,7 +160,10 @@ impl GroupFilesService for GroupFilesServiceImpl {
// shape checks pass (same as app files, audit 2026-06-11 C-2).
new.content_type = sanitize_stored_content_type(&new.content_type);
self.check_write(cx, group_id).await?;
Ok(self.repo.create(group_id, collection, new).await?.id)
let meta = self.repo.create(group_id, collection, new).await?;
self.emit_shared(cx, group_id, "create", collection, &meta, None)
.await;
Ok(meta.id)
}
async fn head(
@@ -169,7 +214,11 @@ impl GroupFilesService for GroupFilesServiceImpl {
return Err(GroupFilesError::NotFound);
};
match self.repo.update(group_id, collection, uuid, upd).await? {
Some(FileUpdated { .. }) => Ok(()),
Some(FileUpdated { new, prev }) => {
self.emit_shared(cx, group_id, "update", collection, &new, Some(&prev))
.await;
Ok(())
}
None => Err(GroupFilesError::NotFound),
}
}
@@ -186,11 +235,14 @@ impl GroupFilesService for GroupFilesServiceImpl {
let Some(uuid) = parse_id(id) else {
return Ok(false);
};
Ok(self
.repo
.delete(group_id, collection, uuid)
.await?
.is_some())
match self.repo.delete(group_id, collection, uuid).await? {
Some(meta) => {
self.emit_shared(cx, group_id, "delete", collection, &meta, Some(&meta))
.await;
Ok(true)
}
None => Ok(false),
}
}
async fn list(