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:
@@ -20,6 +20,7 @@ pub mod kv;
|
||||
pub mod log_sink;
|
||||
pub mod modules;
|
||||
pub mod outbox_writer;
|
||||
pub mod pubsub;
|
||||
pub mod route;
|
||||
pub mod sandbox;
|
||||
pub mod script;
|
||||
@@ -50,6 +51,9 @@ pub use kv::{KvError, KvListPage, KvService, NoopKvService};
|
||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||
pub use modules::{ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource};
|
||||
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
|
||||
pub use pubsub::{
|
||||
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
|
||||
};
|
||||
pub use route::{DispatchMode, HostKind, PathKind, Route};
|
||||
pub use sandbox::ScriptSandbox;
|
||||
pub use script::{Script, ScriptKind};
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ use std::sync::Arc;
|
||||
use crate::{
|
||||
DeadLetterService, DocsService, FilesService, HttpService, KvService, ModuleSource,
|
||||
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopFilesService, NoopHttpService,
|
||||
NoopKvService, NoopModuleSource, ServiceEventEmitter,
|
||||
NoopKvService, NoopModuleSource, NoopPubsubService, PubsubService, ServiceEventEmitter,
|
||||
};
|
||||
|
||||
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
||||
@@ -67,6 +67,12 @@ pub struct Services {
|
||||
/// picloud binary; `NoopFilesService` in tests that don't touch
|
||||
/// files.
|
||||
pub files: Arc<dyn FilesService>,
|
||||
|
||||
/// Durable pub/sub (v1.1.5). Scripts get
|
||||
/// `pubsub::publish_durable(topic, message)`. Backed by a
|
||||
/// publish-time outbox fan-out in the picloud binary;
|
||||
/// `NoopPubsubService` in tests that don't publish.
|
||||
pub pubsub: Arc<dyn PubsubService>,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
@@ -74,6 +80,7 @@ impl Services {
|
||||
/// The picloud binary's `main` wires this up after the DB pool is
|
||||
/// open; tests build it from in-memory fakes.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)] // one Arc per stateful service; a builder would just move the noise
|
||||
pub fn new(
|
||||
kv: Arc<dyn KvService>,
|
||||
docs: Arc<dyn DocsService>,
|
||||
@@ -82,6 +89,7 @@ impl Services {
|
||||
modules: Arc<dyn ModuleSource>,
|
||||
http: Arc<dyn HttpService>,
|
||||
files: Arc<dyn FilesService>,
|
||||
pubsub: Arc<dyn PubsubService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kv,
|
||||
@@ -91,6 +99,7 @@ impl Services {
|
||||
modules,
|
||||
http,
|
||||
files,
|
||||
pubsub,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +118,7 @@ impl Services {
|
||||
Arc::new(NoopModuleSource),
|
||||
Arc::new(NoopHttpService),
|
||||
Arc::new(NoopFilesService),
|
||||
Arc::new(NoopPubsubService),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,15 @@ pub enum TriggerEvent {
|
||||
prev: Option<serde_json::Value>,
|
||||
},
|
||||
|
||||
/// A durable pub/sub publish fired this handler. v1.1.5. Carries
|
||||
/// the topic, the JSON-decoded message, and the publish instant.
|
||||
/// Surfaced to scripts as `ctx.event.pubsub`.
|
||||
Pubsub {
|
||||
topic: String,
|
||||
message: serde_json::Value,
|
||||
published_at: DateTime<Utc>,
|
||||
},
|
||||
|
||||
/// A dead-letter row fired this handler. The original event is
|
||||
/// nested verbatim plus the dead-letter metadata the design notes
|
||||
/// §4 require.
|
||||
@@ -203,6 +212,7 @@ impl TriggerEvent {
|
||||
Self::Docs { .. } => "docs",
|
||||
Self::Cron { .. } => "cron",
|
||||
Self::Files { .. } => "files",
|
||||
Self::Pubsub { .. } => "pubsub",
|
||||
Self::DeadLetter { .. } => "dead_letter",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user