//! `GroupPubsubService` — the §11.6 D2 shared group-TOPIC publish contract. //! //! The pub/sub counterpart to [`crate::GroupKvService`]. A script reaches it via //! the explicit `pubsub::shared_topic("name")` handle (distinct from the app- //! private `pubsub::publish_durable`). A shared topic is **storeless**: there is //! no message log — a publish fans out to the `shared = true` pubsub triggers on //! the OWNING GROUP (resolved by walking the calling app's ancestor chain for //! the nearest group that declares the topic shared). That ancestry walk is the //! isolation boundary — a foreign app's chain never contains the owning group, //! so the topic does not resolve. The trait takes **no** group id (owner //! derivation stays inside the impl), matching `GroupKvService`. //! //! Trust model (§11.6): a publish is a WRITE to shared state, so it requires an //! authenticated principal with editor+ on the owning group (fails closed for //! an anonymous caller), matching `GroupKvService` writes. use async_trait::async_trait; use thiserror::Error; use crate::SdkCallCx; #[async_trait] pub trait GroupPubsubService: Send + Sync { /// Publish `message` to the group shared topic `namespace` (optionally under /// `subtopic`, so `("events", "created")` publishes `events.created`). /// Resolves the owning group (kind `topic`) from `cx.app_id`'s chain, /// requires editor+, and fans out to `shared = true` pubsub triggers on that /// group. Returns the number of delivery rows written (0 when no trigger /// matched — the publish still succeeds). async fn publish( &self, cx: &SdkCallCx, namespace: &str, subtopic: &str, message: serde_json::Value, ) -> Result; } /// Stub for test bundles that don't exercise shared topics. Every call errors so /// accidental use surfaces clearly. #[derive(Debug, Default, Clone, Copy)] pub struct NoopGroupPubsubService; #[async_trait] impl GroupPubsubService for NoopGroupPubsubService { async fn publish( &self, _cx: &SdkCallCx, _namespace: &str, _subtopic: &str, _message: serde_json::Value, ) -> Result { Err(GroupPubsubError::Backend( "group pubsub is not wired in".into(), )) } } /// Failure modes surfaced to the Rhai bridge. #[derive(Debug, Error)] pub enum GroupPubsubError { /// Empty topic namespace; rejected at the SDK boundary. #[error("shared topic name must not be empty")] InvalidTopic, /// No group on the calling app's ancestor chain declares this topic shared — /// the structural "not shared with you" boundary. Also the outcome for a /// foreign app naming another subtree's topic. #[error("topic {0:?} is not a shared group topic for this app")] CollectionNotShared(String), /// Caller lacked the required capability. A publish always needs an /// authenticated editor+ on the owning group (fails closed for anon). #[error("forbidden")] Forbidden, /// Message exceeds the per-message JSON-encoded size cap. #[error("message too large: {actual} bytes exceeds the {limit}-byte cap")] MessageTooLarge { limit: usize, actual: usize }, /// Storage / backend failure. #[error("backend error: {0}")] Backend(String), }