//! `PubsubService` — the v1.1.5 durable pub/sub contract. //! //! `pubsub::publish_durable(topic, message)` writes to the universal //! outbox; the publish-time fan-out inserts one delivery row per //! matching `pubsub` trigger, and each delivery retries / dead-letters //! independently (the dispatcher already handles one-row-equals-one- //! dispatch — no dispatcher changes for pub/sub). //! //! `publish_ephemeral` is committed as a v1.2 addition — the suffix //! naming exists now so users learn "durable by default" from day one. //! //! Topic pattern matching runs in Rust (not SQL) so the trigger-select //! query stays simple. The matcher + validator live here in //! `picloud-shared` so the manager-core publish path, the admin trigger //! endpoint, and tests all agree on the rules. use async_trait::async_trait; use thiserror::Error; use crate::SdkCallCx; #[async_trait] pub trait PubsubService: Send + Sync { /// Durable publish: writes the message to the outbox, fanned out to /// every matching enabled `pubsub` trigger in `cx.app_id`. Succeeds /// silently (zero rows written) when no trigger matches the topic. async fn publish_durable( &self, cx: &SdkCallCx, topic: &str, message: serde_json::Value, ) -> Result<(), PubsubError>; /// Mint an HMAC-signed realtime subscriber token (v1.1.6). Backs the /// `pubsub::subscriber_token(topics, ttl)` Rhai SDK call. The minted /// token authorizes an external SSE client to subscribe to the given /// `topics` for `ttl_seconds` (clamped to the configured bounds; the /// configured default applies when `ttl_seconds` is `None`). /// /// Every topic must already be registered as externally subscribable /// in `cx.app_id`; `cx.principal` must be `Some` (anonymous /// public-HTTP scripts can't mint). See [`PubsubError::SubscriberToken`] /// for the rejection messages. /// /// The default impl errors `Unavailable` so test fakes and the /// `NoopPubsubService` keep compiling; the real minting lives in /// manager-core's `PubsubServiceImpl`. async fn mint_subscriber_token( &self, cx: &SdkCallCx, topics: Vec, ttl_seconds: Option, ) -> Result { let _ = (cx, topics, ttl_seconds); Err(PubsubError::Unavailable( "subscriber tokens are not wired in".into(), )) } } #[derive(Debug, Error)] pub enum PubsubError { /// Empty topic; rejected at the SDK boundary. #[error("topic must not be empty")] EmptyTopic, /// Caller principal lacked the required capability. Only raised when /// `cx.principal.is_some()` (script-as-gate; public HTTP skips it). #[error("forbidden")] Forbidden, /// Serialization / validation failure on the message. #[error("pubsub rejected: {0}")] Rejected(String), /// A `pubsub::subscriber_token` mint was rejected (empty topics, /// unregistered topic, ttl out of range, anonymous caller). The /// string is the full user-facing message; the SDK surfaces it /// verbatim so scripts see the documented wording. #[error("{0}")] SubscriberToken(String), /// Anything else — Postgres unavailable, etc. #[error("pubsub backend error: {0}")] Unavailable(String), } /// Match a stored `topic_pattern` against a published `topic`. /// /// - `"*"` matches every topic. /// - `".*"` matches any topic starting with `"."`. /// - anything else is an exact match. /// /// Mid-pattern wildcards (`*.created`, `a.*.b`) are NOT supported — they /// are rejected at trigger creation by [`validate_topic_pattern`], so /// the only patterns reaching this matcher are exact / prefix / `*`. #[must_use] pub fn topic_matches(pattern: &str, topic: &str) -> bool { if pattern == "*" { return true; } if let Some(prefix) = pattern.strip_suffix('*') { // `prefix` retains the trailing '.', e.g. "user." for "user.*". return topic.starts_with(prefix); } pattern == topic } /// Validate a subscription topic pattern. Accepts exactly: `"*"` /// (universal), `".*"` (prefix wildcard, single trailing star), /// or a literal with no `*` (exact). Everything else — mid-pattern /// wildcards, multiple stars, a star not at the end — is rejected. /// /// # Errors /// /// Returns `Err(message)` with `"unsupported pubsub topic pattern: …"` /// for any unsupported shape (or an empty pattern). pub fn validate_topic_pattern(pattern: &str) -> Result<(), String> { if pattern.is_empty() { return Err("unsupported pubsub topic pattern: ".to_string()); } if pattern == "*" { return Ok(()); } let stars = pattern.matches('*').count(); if stars == 0 { return Ok(()); // exact } if stars == 1 && pattern.ends_with(".*") { return Ok(()); // prefix wildcard } Err(format!("unsupported pubsub topic pattern: {pattern}")) } /// Stub for the test harness so executor-core integration tests can /// build a `Services` bundle without a database. Every call errors. #[derive(Debug, Default, Clone, Copy)] pub struct NoopPubsubService; #[async_trait] impl PubsubService for NoopPubsubService { async fn publish_durable( &self, _cx: &SdkCallCx, _topic: &str, _message: serde_json::Value, ) -> Result<(), PubsubError> { Err(PubsubError::Unavailable("pubsub is not wired in".into())) } } #[cfg(test)] mod tests { use super::*; #[test] fn exact_match() { assert!(topic_matches("user.created", "user.created")); assert!(!topic_matches("user.created", "user.deleted")); assert!(!topic_matches("user.created", "user.created.x")); } #[test] fn prefix_wildcard() { assert!(topic_matches("user.*", "user.created")); assert!(topic_matches("user.*", "user.deleted")); assert!(!topic_matches("user.*", "users.created")); assert!(!topic_matches("user.*", "order.created")); } #[test] fn universal() { assert!(topic_matches("*", "anything")); assert!(topic_matches("*", "a.b.c")); } #[test] fn validation_accepts_supported_shapes() { assert!(validate_topic_pattern("*").is_ok()); assert!(validate_topic_pattern("user.created").is_ok()); assert!(validate_topic_pattern("user.*").is_ok()); assert!(validate_topic_pattern("a.b.c").is_ok()); } #[test] fn validation_rejects_unsupported_shapes() { for bad in ["*.created", "**", "a.*.b", "user.*x", "*user", ""] { assert!( validate_topic_pattern(bad).is_err(), "expected {bad:?} to be rejected" ); } } }