//! `GroupQueueServiceImpl` — wires `GroupQueueRepo` + the group-collection //! registry underneath the `picloud_shared::GroupQueueService` trait that //! scripts reach via the `queue::shared_collection("name")` Rhai handle //! (§11.6 D3). //! //! Resolves the OWNING GROUP (kind `queue`) from `cx.app_id`'s chain (the //! isolation boundary), enforces editor+ (`GroupQueueEnqueue`, fails closed on //! anon — enqueue is a shared write), caps the payload size, then writes to the //! group-keyed store. Consumption is out of band (competing per-descendant //! materialized consumers, in the dispatcher). use std::sync::Arc; use async_trait::async_trait; use chrono::Utc; use picloud_shared::{ EnqueueOpts, GroupId, GroupQueueError, GroupQueueService, QueueMessageId, SdkCallCx, }; use crate::authz::{self, AuthzRepo, Capability}; use crate::group_collection_repo::GroupCollectionResolver; use crate::group_queue_repo::{GroupQueueRepo, NewGroupQueueMessage}; use crate::queue_service::queue_max_payload_bytes_from_env; /// The registry `kind` this service resolves. const KIND_QUEUE: &str = "queue"; pub struct GroupQueueServiceImpl { repo: Arc, resolver: Arc, authz: Arc, max_payload_bytes: usize, } impl GroupQueueServiceImpl { #[must_use] pub fn new( repo: Arc, resolver: Arc, authz: Arc, ) -> Self { Self { repo, resolver, authz, max_payload_bytes: queue_max_payload_bytes_from_env(), } } async fn owning_group( &self, cx: &SdkCallCx, collection: &str, ) -> Result { if collection.is_empty() { return Err(GroupQueueError::InvalidCollection); } self.resolver .resolve_owning_group(cx.app_id, collection, KIND_QUEUE) .await .map_err(|e| GroupQueueError::Backend(e.to_string()))? .ok_or_else(|| GroupQueueError::CollectionNotShared(collection.to_string())) } async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupQueueError> { authz::script_gate_require_principal( &*self.authz, cx, Capability::GroupQueueEnqueue(group_id), || GroupQueueError::Forbidden, GroupQueueError::Backend, ) .await } } #[async_trait] impl GroupQueueService for GroupQueueServiceImpl { async fn enqueue( &self, cx: &SdkCallCx, collection: &str, payload: serde_json::Value, opts: EnqueueOpts, ) -> Result { if collection.is_empty() { return Err(GroupQueueError::InvalidCollection); } // Size cap first (no DB) — a cheap reject before resolve/authz. let encoded_len = serde_json::to_vec(&payload) .map(|v| v.len()) .map_err(|e| GroupQueueError::Backend(format!("encode payload: {e}")))?; if encoded_len > self.max_payload_bytes { return Err(GroupQueueError::PayloadTooLarge { limit: self.max_payload_bytes, actual: encoded_len, }); } let max_attempts = opts.max_attempts.unwrap_or(3); if !(1..=20).contains(&max_attempts) { return Err(GroupQueueError::InvalidOpts( "queue::enqueue: max_attempts must be in [1, 20]".into(), )); } if let Some(delay) = opts.delay_ms { if !(0..=86_400_000).contains(&delay) { return Err(GroupQueueError::InvalidOpts( "queue::enqueue: delay_ms must be in [0, 86_400_000]".into(), )); } } let group_id = self.owning_group(cx, collection).await?; self.check_write(cx, group_id).await?; let deliver_after = opts .delay_ms .and_then(chrono::Duration::try_milliseconds) .map(|d| Utc::now() + d); self.repo .enqueue(NewGroupQueueMessage { group_id, collection: collection.to_string(), payload, deliver_after, max_attempts, enqueued_by_principal: cx.principal.as_ref().map(|p| p.user_id), }) .await .map_err(|e| GroupQueueError::Backend(e.to_string())) } async fn depth(&self, cx: &SdkCallCx, collection: &str) -> Result { let group_id = self.owning_group(cx, collection).await?; // Depth is a read — reads are open (any subtree script). self.repo .depth(group_id, collection) .await .map_err(|e| GroupQueueError::Backend(e.to_string())) } async fn depth_pending( &self, cx: &SdkCallCx, collection: &str, ) -> Result { let group_id = self.owning_group(cx, collection).await?; self.repo .depth_pending(group_id, collection) .await .map_err(|e| GroupQueueError::Backend(e.to_string())) } }