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>
101 lines
3.6 KiB
Rust
101 lines
3.6 KiB
Rust
//! `pubsub::` Rhai bridge — durable publish (v1.1.5).
|
|
//!
|
|
//! ```rhai
|
|
//! pubsub::publish_durable("user.created", #{ user_id: "abc" });
|
|
//! pubsub::publish_durable("metric", 42);
|
|
//! ```
|
|
//!
|
|
//! No handle pattern (topics ARE the grouping unit, so there's no
|
|
//! `::collection(...)`). The message is any JSON-serializable Rhai value
|
|
//! — Maps, Arrays, strings, numbers, bools, unit, and **Blobs (which
|
|
//! encode as base64 strings** so trigger handlers see them as base64 on
|
|
//! the wire). Nested blobs are encoded at any depth.
|
|
//!
|
|
//! `app_id` is derived from `cx.app_id` in the service — it never
|
|
//! appears in the script-side signature, preserving cross-app
|
|
//! isolation.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use base64::engine::general_purpose::STANDARD;
|
|
use base64::Engine as _;
|
|
use picloud_shared::{PubsubError, SdkCallCx, Services};
|
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
|
use serde_json::Value as Json;
|
|
use tokio::runtime::Handle as TokioHandle;
|
|
|
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
|
let svc = services.pubsub.clone();
|
|
let mut module = Module::new();
|
|
{
|
|
let svc = svc.clone();
|
|
let cx = cx.clone();
|
|
module.set_native_fn(
|
|
"publish_durable",
|
|
move |topic: &str, message: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
|
let json = message_to_json(&message);
|
|
let svc = svc.clone();
|
|
let cx = cx.clone();
|
|
block_on(async move { svc.publish_durable(&cx, topic, json).await })
|
|
},
|
|
);
|
|
}
|
|
engine.register_static_module("pubsub", module.into());
|
|
}
|
|
|
|
/// Convert a Rhai `Dynamic` message into JSON, base64-encoding any
|
|
/// `Blob` (at any nesting depth). Mirrors `bridge::dynamic_to_json` but
|
|
/// adds the blob arm the pub/sub wire contract requires.
|
|
fn message_to_json(value: &Dynamic) -> Json {
|
|
// Blob must be checked before the generic array path (a Blob is a
|
|
// `Vec<u8>`, distinct from a Rhai `Array`).
|
|
if value.is_blob() {
|
|
let blob = value.clone().into_blob().unwrap_or_default();
|
|
return Json::String(STANDARD.encode(&blob));
|
|
}
|
|
if value.is_unit() {
|
|
return Json::Null;
|
|
}
|
|
if let Ok(b) = value.as_bool() {
|
|
return Json::Bool(b);
|
|
}
|
|
if let Ok(i) = value.as_int() {
|
|
return Json::Number(i.into());
|
|
}
|
|
if let Ok(f) = value.as_float() {
|
|
return serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number);
|
|
}
|
|
if value.is_string() {
|
|
return Json::String(value.clone().into_string().unwrap_or_default());
|
|
}
|
|
if let Some(arr) = value.clone().try_cast::<Array>() {
|
|
return Json::Array(arr.iter().map(message_to_json).collect());
|
|
}
|
|
if let Some(map) = value.clone().try_cast::<Map>() {
|
|
let mut out = serde_json::Map::new();
|
|
for (k, v) in map {
|
|
out.insert(k.to_string(), message_to_json(&v));
|
|
}
|
|
return Json::Object(out);
|
|
}
|
|
Json::String(value.to_string())
|
|
}
|
|
|
|
/// Run an async future inside the synchronous Rhai context. Mirrors
|
|
/// `kv::block_on`.
|
|
fn block_on<F>(fut: F) -> Result<(), Box<EvalAltResult>>
|
|
where
|
|
F: std::future::Future<Output = Result<(), PubsubError>> + Send,
|
|
{
|
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
|
EvalAltResult::ErrorRuntime(
|
|
format!("pubsub: no tokio runtime available: {e}").into(),
|
|
rhai::Position::NONE,
|
|
)
|
|
.into()
|
|
})?;
|
|
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
|
EvalAltResult::ErrorRuntime(format!("pubsub: {err}").into(), rhai::Position::NONE).into()
|
|
})
|
|
}
|