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

@@ -22,7 +22,10 @@
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{GroupId, GroupKvError, GroupKvService, KvListPage, SdkCallCx};
use picloud_shared::{
GroupId, GroupKvError, GroupKvService, KvListPage, NoopEventEmitter, SdkCallCx, ServiceEvent,
ServiceEventEmitter,
};
use crate::authz::{self, AuthzRepo, Capability};
use crate::group_collection_repo::GroupCollectionResolver;
@@ -37,6 +40,10 @@ pub struct GroupKvServiceImpl {
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
max_value_bytes: usize,
/// §11.6: fires `shared = true` triggers on a shared-collection write.
/// Defaults to the noop emitter; the host wires the outbox emitter via
/// [`Self::with_events`].
events: Arc<dyn ServiceEventEmitter>,
}
impl GroupKvServiceImpl {
@@ -49,6 +56,14 @@ impl GroupKvServiceImpl {
Self::with_max_value_bytes(repo, resolver, authz, kv_max_value_bytes_from_env())
}
/// Wire the event emitter (§11.6 shared-collection triggers). Without this
/// the service uses the noop emitter and shared writes fire nothing.
#[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 GroupKvRepo>,
@@ -57,6 +72,7 @@ impl GroupKvServiceImpl {
max_value_bytes: usize,
) -> Self {
Self {
events: Arc::new(NoopEventEmitter),
repo,
resolver,
authz,
@@ -82,6 +98,38 @@ impl GroupKvServiceImpl {
.ok_or_else(|| GroupKvError::CollectionNotShared(collection.to_string()))
}
/// §11.6: fire `shared = true` kv triggers on the owning group. Best-effort
/// — an emit failure is logged, never surfaced (the write already
/// committed), matching the per-app `KvServiceImpl` behaviour.
async fn emit_shared(
&self,
cx: &SdkCallCx,
group_id: GroupId,
op: &'static str,
collection: &str,
key: &str,
payload: Option<serde_json::Value>,
) {
if let Err(e) = self
.events
.emit_shared(
cx,
group_id,
ServiceEvent {
source: "kv",
op,
collection: Some(collection.to_string()),
key: Some(key.to_string()),
payload,
old_payload: None,
},
)
.await
{
tracing::error!(error = %e, source = "kv", op, event_emit_failure = true, "shared event emit failed");
}
}
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupKvError> {
authz::script_gate(
&*self.authz,
@@ -144,7 +192,22 @@ impl GroupKvService for GroupKvServiceImpl {
});
}
self.check_write(cx, group_id).await?;
self.repo.set(group_id, collection, key, value).await?;
// Determine insert vs update for the event op (best-effort, like the
// per-app path — the has+set is non-transactional but triggers are
// fire-and-forget).
let existed = self.repo.has(group_id, collection, key).await?;
self.repo
.set(group_id, collection, key, value.clone())
.await?;
self.emit_shared(
cx,
group_id,
if existed { "update" } else { "insert" },
collection,
key,
Some(value),
)
.await;
Ok(())
}
@@ -156,7 +219,12 @@ impl GroupKvService for GroupKvServiceImpl {
) -> Result<bool, GroupKvError> {
let group_id = self.owning_group(cx, collection).await?;
self.check_write(cx, group_id).await?;
Ok(self.repo.delete(group_id, collection, key).await?.is_some())
let deleted = self.repo.delete(group_id, collection, key).await?.is_some();
if deleted {
self.emit_shared(cx, group_id, "delete", collection, key, None)
.await;
}
Ok(deleted)
}
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, GroupKvError> {