//! `QueueService` — the v1.1.9 durable queue contract. //! //! `queue::enqueue(name, message, opts?)` writes one row to //! `queue_messages`; the dispatcher's queue arm claims it later via //! `FOR UPDATE SKIP LOCKED` and fires the registered `queue:receive` //! trigger (commit 6). On handler success the row is deleted (ack); //! on throw it's nacked (claim cleared, retry per the trigger's policy //! with `deliver_after = NOW() + backoff`); when `attempt >= max_attempts` //! the message is moved to `dead_letters`. //! //! No `peek`/`dequeue`/`purge` script-side surface — consumers are //! triggers. Exposing manual dequeue would force PiCloud to expose //! visibility-timeout + nack semantics to script-land, which is the //! whole reason queue is trigger-based. //! //! Identity tuple: `(cx.app_id, queue_name)`. Cross-app isolation is //! enforced because every method derives `app_id` from `cx.app_id` — //! never from a script argument. use async_trait::async_trait; use thiserror::Error; use crate::{QueueMessageId, SdkCallCx}; /// Optional per-enqueue overrides passed to [`QueueService::enqueue`]. #[derive(Debug, Clone, Copy, Default)] pub struct EnqueueOpts { /// Defer delivery until `NOW() + delay_ms`. Default: immediate. pub delay_ms: Option, /// Override the default `max_attempts` (3) for THIS message. Clamped /// to `[1, 20]` at the SDK boundary so a script can't enqueue a /// retry-forever message. pub max_attempts: Option, } #[async_trait] pub trait QueueService: Send + Sync { /// Enqueue one message onto `(cx.app_id, queue_name)`. Returns the /// new message id (forensic; not surfaced to scripts in the /// script-side return). async fn enqueue( &self, cx: &SdkCallCx, queue_name: &str, payload: serde_json::Value, opts: EnqueueOpts, ) -> Result; /// Total queued messages (claimed + unclaimed + retrying-but-not-deleted) /// for `(cx.app_id, queue_name)`. Surfaces as `queue::depth(name)`. async fn depth(&self, cx: &SdkCallCx, queue_name: &str) -> Result; /// Currently-claimable count — `claim_token IS NULL` AND `deliver_after` /// has passed. Surfaces as `queue::depth_pending(name)`. Diverges from /// `depth` for delayed / in-flight messages. async fn depth_pending(&self, cx: &SdkCallCx, queue_name: &str) -> Result; } #[derive(Debug, Error)] pub enum QueueError { #[error("queue_name must not be empty")] EmptyName, /// Caller principal lacked the required capability. Only raised when /// `cx.principal.is_some()` (script-as-gate; public HTTP skips it). /// Sibling shape across KvError / DocsError / PubsubError — uniform /// for 403 translation at the HTTP boundary. #[error("forbidden")] Forbidden, /// Payload could not be serialized (e.g. a Rhai FnPtr / closure /// captured into the message). Rejected at the SDK boundary; surface /// the wording verbatim so scripts see the documented message. #[error("queue rejected: {0}")] Rejected(String), /// max_attempts (or delay_ms) outside the allowed bounds. #[error("{0}")] InvalidOpts(String), /// JSON-encoded payload exceeded the configured `max_payload_bytes` /// cap (`PICLOUD_QUEUE_MAX_PAYLOAD_BYTES`). Defends against script /// queue::enqueue calls filling disk with multi-MB payloads. #[error("queue: payload too large ({actual} bytes; limit {limit})")] PayloadTooLarge { limit: usize, actual: usize }, /// Backend failure (Postgres unavailable, etc.). Named `Backend` to /// align with KvError / DocsError / FilesError / SecretsError. #[error("queue backend error: {0}")] Backend(String), } /// Test-only stub: every call errors `Unavailable`. Used by harnesses /// that build a `Services` bundle without a database. #[derive(Debug, Default, Clone, Copy)] pub struct NoopQueueService; #[async_trait] impl QueueService for NoopQueueService { async fn enqueue( &self, _cx: &SdkCallCx, _queue_name: &str, _payload: serde_json::Value, _opts: EnqueueOpts, ) -> Result { Err(QueueError::Backend("queue is not wired in".into())) } async fn depth(&self, _cx: &SdkCallCx, _queue_name: &str) -> Result { Err(QueueError::Backend("queue is not wired in".into())) } async fn depth_pending(&self, _cx: &SdkCallCx, _queue_name: &str) -> Result { Err(QueueError::Backend("queue is not wired in".into())) } }