feat(v1.1.5): pubsub::publish_durable SDK + pubsub:* triggers
Durable pub/sub through the universal outbox — the sixth trigger kind. - `pubsub::publish_durable(topic, message)` Rhai SDK (no handle; topics ARE the grouping unit). Message JSON-encoded; Blobs base64 at any depth. - `PubsubService` trait in picloud-shared with the topic matcher + validator (exact / `<prefix>.*` / `*`; mid-pattern wildcards rejected). `PostgresPubsubRepo` + `PubsubServiceImpl` in manager-core. - Publish-time fan-out: one outbox row per matching enabled pubsub trigger, all in ONE transaction (no half-fan-out on crash). No matching trigger → publish succeeds silently, zero rows. - `pubsub:*` trigger kind via Layout-E (0020: widen both CHECKs + pubsub_trigger_details + partial index), TriggerEvent::Pubsub + ctx.event.pubsub, dispatcher arm, admin endpoint POST /triggers/pubsub (validates topic pattern + reuses validate_trigger_target). - AppPubsubPublish capability → script:write (seven-scope held). - Dashboard Pub/Sub trigger form on the Triggers tab + list rendering. publish_ephemeral stays deferred to v1.2. ~18 new tests (service in-memory incl. transactional-rollback, shared matcher, bridge encoding). No DB required for the suite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
161
crates/shared/src/pubsub.rs
Normal file
161
crates/shared/src/pubsub.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
//! `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>;
|
||||
}
|
||||
|
||||
#[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),
|
||||
|
||||
/// Anything else — Postgres unavailable, etc.
|
||||
#[error("pubsub backend error: {0}")]
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
/// Match a stored `topic_pattern` against a published `topic`.
|
||||
///
|
||||
/// - `"*"` matches every topic.
|
||||
/// - `"<prefix>.*"` matches any topic starting with `"<prefix>."`.
|
||||
/// - 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>.*"` (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: <empty>".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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user