Server-side realtime SSE on per-app pub/sub topics, plus the three
v1.1.5 follow-ups and the version bumps.
Realtime:
- topics registry (0021) + admin endpoints + Capability::AppTopicManage
(-> app:admin; no new scope).
- GET /realtime/topics/{topic} SSE endpoint (orchestrator-core data
plane): Host -> app, RealtimeAuthority gate (404 missing/internal,
401 bad/absent token), broadcast::Receiver stream + heartbeat.
- RealtimeBroadcaster / RealtimeEvent / RealtimeAuthority traits
(picloud-shared); InProcessBroadcaster + GC (orchestrator-core);
DB-backed RealtimeAuthorityImpl (manager-core). Publish path fans out
to in-process subscribers after the durable outbox commit (best-effort,
panic-isolated).
- HMAC subscriber tokens (subscriber_token.rs) + app_secrets table (0022)
+ pubsub::subscriber_token SDK (schema 1.6 -> 1.7). TTL clamp + env
overrides.
- Dashboard Topics tab (register/list/edit/delete, prominent external
badge, flip confirmation).
v1.1.5 follow-ups:
- Empty blobs accepted (NewFile/FileUpdate::validate) + round-trip test.
- Orphan *.tmp.* sweeper (spawn_files_orphan_sweep).
- Dispatcher e2e tests, one per trigger kind (DATABASE_URL-gated).
Versions: workspace 1.1.6, SDK 1.7, dashboard 0.12.0. Schema-snapshot
golden re-blessed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
195 lines
6.7 KiB
Rust
195 lines
6.7 KiB
Rust
//! `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<String>,
|
|
ttl_seconds: Option<i64>,
|
|
) -> Result<String, PubsubError> {
|
|
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.
|
|
/// - `"<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"
|
|
);
|
|
}
|
|
}
|
|
}
|