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:
MechaCat02
2026-06-03 21:37:06 +02:00
parent 6e132b6ee0
commit 834c787ee1
25 changed files with 1240 additions and 16 deletions

161
crates/shared/src/pubsub.rs Normal file
View 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"
);
}
}
}