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

View File

@@ -167,7 +167,8 @@ impl Dispatcher {
| OutboxSourceKind::Docs
| OutboxSourceKind::DeadLetter
| OutboxSourceKind::Cron
| OutboxSourceKind::Files => {
| OutboxSourceKind::Files
| OutboxSourceKind::Pubsub => {
let resolved = self.resolve_trigger(&row).await?;
let req = match self.build_exec_request(&row, &resolved).await {
Ok(req) => req,

View File

@@ -43,6 +43,8 @@ pub mod module_source;
pub mod outbox_event_emitter;
pub mod outbox_repo;
pub mod principal_resolver;
pub mod pubsub_repo;
pub mod pubsub_service;
pub mod repo;
pub mod route_admin;
pub mod route_repo;
@@ -113,6 +115,8 @@ pub use outbox_repo::{
NewOutboxRow, OutboxRepo, OutboxRepoError, OutboxRow, OutboxSourceKind, PostgresOutboxRepo,
};
pub use principal_resolver::{AdminPrincipalResolver, PrincipalResolver, PrincipalResolverError};
pub use pubsub_repo::{PostgresPubsubRepo, PublishCtx, PubsubRepo, PubsubRepoError};
pub use pubsub_service::PubsubServiceImpl;
pub use repo::{
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError,
@@ -123,8 +127,8 @@ pub use sandbox::{CeilingError, SandboxCeiling};
pub use trigger_config::{BackoffShape, TriggerConfig};
pub use trigger_repo::{
collection_matches, CreateDeadLetterTrigger, CreateDocsTrigger, CreateFilesTrigger,
CreateKvTrigger, DeadLetterTriggerMatch, DocsTriggerMatch, FilesTriggerMatch, KvTriggerMatch,
PostgresTriggerRepo, Trigger, TriggerDetails, TriggerDispatchMode, TriggerKind, TriggerRepo,
TriggerRepoError,
CreateKvTrigger, CreatePubsubTrigger, DeadLetterTriggerMatch, DocsTriggerMatch,
FilesTriggerMatch, KvTriggerMatch, PostgresTriggerRepo, Trigger, TriggerDetails,
TriggerDispatchMode, TriggerKind, TriggerRepo, TriggerRepoError,
};
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};

View File

@@ -29,6 +29,8 @@ pub enum OutboxSourceKind {
Cron,
/// v1.1.5.
Files,
/// v1.1.5.
Pubsub,
}
impl OutboxSourceKind {
@@ -41,6 +43,7 @@ impl OutboxSourceKind {
Self::DeadLetter => "dead_letter",
Self::Cron => "cron",
Self::Files => "files",
Self::Pubsub => "pubsub",
}
}
@@ -53,6 +56,7 @@ impl OutboxSourceKind {
"dead_letter" => Some(Self::DeadLetter),
"cron" => Some(Self::Cron),
"files" => Some(Self::Files),
"pubsub" => Some(Self::Pubsub),
_ => None,
}
}

View File

@@ -0,0 +1,118 @@
//! `PubsubRepo` — publish-time fan-out for the v1.1.5 `pubsub::*` SDK.
//!
//! `publish_durable` writes one outbox row per matching enabled `pubsub`
//! trigger, all inside a single transaction so a partial fan-out (some
//! subscribers got rows, others didn't, then a crash) can't happen.
//! Each delivery row then retries / dead-letters independently through
//! the existing dispatcher — no pub/sub-specific dispatch branching.
//!
//! Topic pattern matching runs in Rust (`picloud_shared::topic_matches`)
//! against the small set of the app's enabled pubsub triggers, keeping
//! the SELECT trivial. v1.2 can add a topic-trie index if fan-out
//! becomes a hot path.
use async_trait::async_trait;
use picloud_shared::{topic_matches, AdminUserId, AppId, ExecutionId};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum PubsubRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
}
/// The execution-context bits a fan-out needs to stamp onto each outbox
/// row. Derived from the publishing script's `SdkCallCx`.
#[derive(Debug, Clone, Copy)]
pub struct PublishCtx {
pub app_id: AppId,
pub origin_principal: Option<AdminUserId>,
pub trigger_depth: u32,
pub root_execution_id: ExecutionId,
}
#[async_trait]
pub trait PubsubRepo: Send + Sync {
/// Fan out a publish to every matching enabled pubsub trigger in
/// `ctx.app_id`, inserting one outbox row each in a SINGLE
/// transaction. `event_payload` is the serialized
/// `TriggerEvent::Pubsub`. Returns the number of delivery rows
/// written (0 when no trigger matched — the publish still succeeds).
async fn fan_out_publish(
&self,
ctx: PublishCtx,
topic: &str,
event_payload: serde_json::Value,
) -> Result<u32, PubsubRepoError>;
}
pub struct PostgresPubsubRepo {
pool: PgPool,
}
impl PostgresPubsubRepo {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct PubsubTriggerRow {
id: Uuid,
script_id: Uuid,
topic_pattern: String,
}
#[async_trait]
impl PubsubRepo for PostgresPubsubRepo {
async fn fan_out_publish(
&self,
ctx: PublishCtx,
topic: &str,
event_payload: serde_json::Value,
) -> Result<u32, PubsubRepoError> {
let mut tx = self.pool.begin().await?;
// Load all enabled pubsub triggers for the app; filter by topic
// pattern in Rust (keeps the query simple, honours the
// empty/`*`/prefix semantics without teaching SQL about globs).
let rows: Vec<PubsubTriggerRow> = sqlx::query_as(
"SELECT t.id, t.script_id, d.topic_pattern \
FROM triggers t \
JOIN pubsub_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'pubsub' AND t.enabled = TRUE",
)
.bind(ctx.app_id.into_inner())
.fetch_all(&mut *tx)
.await?;
let mut written: u32 = 0;
for r in rows {
if !topic_matches(&r.topic_pattern, topic) {
continue;
}
sqlx::query(
"INSERT INTO outbox ( \
app_id, source_kind, trigger_id, script_id, reply_to, \
payload, origin_principal, trigger_depth, root_execution_id \
) VALUES ($1, 'pubsub', $2, $3, NULL, $4, $5, $6, $7)",
)
.bind(ctx.app_id.into_inner())
.bind(r.id)
.bind(r.script_id)
.bind(&event_payload)
.bind(ctx.origin_principal.map(AdminUserId::into_inner))
.bind(i32::try_from(ctx.trigger_depth.saturating_add(1)).unwrap_or(1))
.bind(ctx.root_execution_id.into_inner())
.execute(&mut *tx)
.await?;
written += 1;
}
// Commit once — all rows or none.
tx.commit().await?;
Ok(written)
}
}

View File

@@ -0,0 +1,320 @@
//! `PubsubServiceImpl` — wires `PubsubRepo` underneath the
//! `picloud_shared::PubsubService` trait scripts see via the Rhai
//! bridge.
//!
//! Mirrors the other stateful services: script-as-gate authz
//! (`AppPubsubPublish`, skipped when `cx.principal` is `None`), with the
//! backend doing a publish-time outbox fan-out instead of a row write.
//! No `ServiceEventEmitter` here — pub/sub publishes directly to the
//! outbox; it doesn't mutate local data that other triggers observe.
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{PubsubError, PubsubService, SdkCallCx, TriggerEvent};
use crate::authz::{self, AuthzRepo, Capability};
use crate::pubsub_repo::{PublishCtx, PubsubRepo, PubsubRepoError};
pub struct PubsubServiceImpl {
repo: Arc<dyn PubsubRepo>,
authz: Arc<dyn AuthzRepo>,
}
impl PubsubServiceImpl {
#[must_use]
pub fn new(repo: Arc<dyn PubsubRepo>, authz: Arc<dyn AuthzRepo>) -> Self {
Self { repo, authz }
}
async fn check_publish(&self, cx: &SdkCallCx) -> Result<(), PubsubError> {
if let Some(ref principal) = cx.principal {
authz::require(
&*self.authz,
principal,
Capability::AppPubsubPublish(cx.app_id),
)
.await
.map_err(|_| PubsubError::Forbidden)?;
}
Ok(())
}
}
impl From<PubsubRepoError> for PubsubError {
fn from(e: PubsubRepoError) -> Self {
Self::Unavailable(e.to_string())
}
}
#[async_trait]
impl PubsubService for PubsubServiceImpl {
async fn publish_durable(
&self,
cx: &SdkCallCx,
topic: &str,
message: serde_json::Value,
) -> Result<(), PubsubError> {
if topic.trim().is_empty() {
return Err(PubsubError::EmptyTopic);
}
self.check_publish(cx).await?;
// `published_at` is stamped on the manager side so every
// delivery agrees on one instant.
let event = TriggerEvent::Pubsub {
topic: topic.to_string(),
message,
published_at: chrono::Utc::now(),
};
let payload = serde_json::to_value(&event)
.map_err(|e| PubsubError::Rejected(format!("event serialize: {e}")))?;
let publish_ctx = PublishCtx {
app_id: cx.app_id,
origin_principal: cx.principal.as_ref().map(|p| p.user_id),
trigger_depth: cx.trigger_depth,
root_execution_id: cx.root_execution_id,
};
// No matching triggers → 0 rows written, publish still succeeds.
self.repo
.fan_out_publish(publish_ctx, topic, payload)
.await?;
Ok(())
}
}
// ----------------------------------------------------------------------------
// Tests — in-memory PubsubRepo so unit tests don't need Postgres. The
// real transactional fan-out is covered against a live DB by the
// integration suite; the in-memory fake models the all-or-nothing
// commit so the rollback semantics can be asserted without a DB.
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::{AuthzError, AuthzRepo};
use async_trait::async_trait;
use picloud_shared::{
topic_matches, AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal,
RequestId, ScriptId, UserId,
};
use std::sync::Mutex;
/// In-memory pubsub repo. Holds a set of `(app, pattern)`
/// subscriptions and records the outbox rows a publish would write.
/// `fail_at` simulates a mid-fan-out INSERT failure: when set to
/// `Some(n)`, the n-th (1-indexed) matching row errors and NOTHING
/// is recorded — modelling the single-transaction rollback.
struct InMemoryPubsubRepo {
subs: Vec<(AppId, String)>,
written: Mutex<Vec<(AppId, String)>>,
fail_at: Option<usize>,
}
impl InMemoryPubsubRepo {
fn new(subs: Vec<(AppId, String)>) -> Self {
Self {
subs,
written: Mutex::new(Vec::new()),
fail_at: None,
}
}
fn written_count(&self) -> usize {
self.written.lock().unwrap().len()
}
}
#[async_trait]
impl PubsubRepo for InMemoryPubsubRepo {
async fn fan_out_publish(
&self,
ctx: PublishCtx,
topic: &str,
_event_payload: serde_json::Value,
) -> Result<u32, PubsubRepoError> {
let matches: Vec<&(AppId, String)> = self
.subs
.iter()
.filter(|(a, pat)| *a == ctx.app_id && topic_matches(pat, topic))
.collect();
let mut staged = Vec::new();
for (i, _) in matches.iter().enumerate() {
if self.fail_at == Some(i + 1) {
// Rollback: nothing was committed.
return Err(PubsubRepoError::Db(sqlx::Error::Protocol(
"simulated insert failure".into(),
)));
}
staged.push((ctx.app_id, topic.to_string()));
}
let n = staged.len();
self.written.lock().unwrap().extend(staged);
Ok(u32::try_from(n).unwrap_or(u32::MAX))
}
}
#[derive(Default)]
struct DenyingAuthzRepo;
#[async_trait]
impl AuthzRepo for DenyingAuthzRepo {
async fn membership(
&self,
_user_id: UserId,
_app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
}
#[derive(Default)]
struct EditorAuthzRepo;
#[async_trait]
impl AuthzRepo for EditorAuthzRepo {
async fn membership(
&self,
_user_id: UserId,
_app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(Some(AppRole::Editor))
}
}
fn anon_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx {
app_id,
script_id: ScriptId::new(),
principal: None,
execution_id: ExecutionId::new(),
request_id: RequestId::new(),
trigger_depth: 0,
root_execution_id: ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
fn member_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx {
principal: Some(Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Member,
scopes: None,
app_binding: None,
}),
..anon_cx(app_id)
}
}
fn svc(repo: Arc<dyn PubsubRepo>, authz: Arc<dyn AuthzRepo>) -> PubsubServiceImpl {
PubsubServiceImpl::new(repo, authz)
}
#[tokio::test]
async fn publish_writes_one_row_per_matching_trigger() {
let app = AppId::new();
let repo = Arc::new(InMemoryPubsubRepo::new(vec![
(app, "user.*".into()),
(app, "user.created".into()),
(app, "order.*".into()), // does not match
]));
let files = svc(repo.clone(), Arc::new(DenyingAuthzRepo));
files
.publish_durable(&anon_cx(app), "user.created", serde_json::json!({"id": 1}))
.await
.unwrap();
// Two of the three subscriptions match "user.created".
assert_eq!(repo.written_count(), 2);
}
#[tokio::test]
async fn no_matching_trigger_succeeds_silently() {
let app = AppId::new();
let repo = Arc::new(InMemoryPubsubRepo::new(vec![(app, "order.*".into())]));
let svc = svc(repo.clone(), Arc::new(DenyingAuthzRepo));
svc.publish_durable(&anon_cx(app), "user.created", serde_json::json!(1))
.await
.unwrap();
assert_eq!(repo.written_count(), 0);
}
#[tokio::test]
async fn empty_topic_rejected() {
let app = AppId::new();
let repo = Arc::new(InMemoryPubsubRepo::new(vec![]));
let svc = svc(repo, Arc::new(DenyingAuthzRepo));
let err = svc
.publish_durable(&anon_cx(app), " ", serde_json::json!(1))
.await
.unwrap_err();
assert!(matches!(err, PubsubError::EmptyTopic));
}
#[tokio::test]
async fn cross_app_isolation() {
let app_a = AppId::new();
let app_b = AppId::new();
// The only subscription belongs to app B.
let repo = Arc::new(InMemoryPubsubRepo::new(vec![(app_b, "*".into())]));
let svc = svc(repo.clone(), Arc::new(DenyingAuthzRepo));
// App A publishes — app B's trigger must NOT fire.
svc.publish_durable(&anon_cx(app_a), "user.created", serde_json::json!(1))
.await
.unwrap();
assert_eq!(repo.written_count(), 0);
}
#[tokio::test]
async fn fan_out_is_transactional_all_or_nothing() {
let app = AppId::new();
let mut repo = InMemoryPubsubRepo::new(vec![
(app, "*".into()),
(app, "user.*".into()),
(app, "user.created".into()),
]);
repo.fail_at = Some(3); // fail on the 3rd matching insert
let repo = Arc::new(repo);
let svc = svc(repo.clone(), Arc::new(DenyingAuthzRepo));
let err = svc
.publish_durable(&anon_cx(app), "user.created", serde_json::json!(1))
.await
.unwrap_err();
assert!(matches!(err, PubsubError::Unavailable(_)));
// Rollback: no partial fan-out survived.
assert_eq!(repo.written_count(), 0);
}
#[tokio::test]
async fn anonymous_cx_skips_authz() {
let app = AppId::new();
let repo = Arc::new(InMemoryPubsubRepo::new(vec![]));
let svc = svc(repo, Arc::new(DenyingAuthzRepo));
// No principal → no authz check even with a denying repo.
svc.publish_durable(&anon_cx(app), "t", serde_json::json!(1))
.await
.unwrap();
}
#[tokio::test]
async fn member_without_role_is_forbidden() {
let app = AppId::new();
let repo = Arc::new(InMemoryPubsubRepo::new(vec![]));
let svc = svc(repo, Arc::new(DenyingAuthzRepo));
let err = svc
.publish_durable(&member_cx(app), "t", serde_json::json!(1))
.await
.unwrap_err();
assert!(matches!(err, PubsubError::Forbidden));
}
#[tokio::test]
async fn member_with_editor_role_allowed() {
let app = AppId::new();
let repo = Arc::new(InMemoryPubsubRepo::new(vec![]));
let svc = svc(repo, Arc::new(EditorAuthzRepo));
svc.publish_durable(&member_cx(app), "t", serde_json::json!(1))
.await
.unwrap();
}
}

View File

@@ -55,6 +55,8 @@ pub enum TriggerKind {
Cron,
/// v1.1.5.
Files,
/// v1.1.5.
Pubsub,
}
impl TriggerKind {
@@ -66,6 +68,7 @@ impl TriggerKind {
Self::DeadLetter => "dead_letter",
Self::Cron => "cron",
Self::Files => "files",
Self::Pubsub => "pubsub",
}
}
@@ -77,6 +80,7 @@ impl TriggerKind {
"dead_letter" => Some(Self::DeadLetter),
"cron" => Some(Self::Cron),
"files" => Some(Self::Files),
"pubsub" => Some(Self::Pubsub),
_ => None,
}
}
@@ -131,6 +135,8 @@ pub enum TriggerDetails {
collection_glob: String,
ops: Vec<FilesEventOp>,
},
/// v1.1.5. A topic pattern: exact, `<prefix>.*`, or `*`.
Pubsub { topic_pattern: String },
}
/// Create payload for a KV trigger. Defaults applied at the admin
@@ -213,6 +219,19 @@ pub struct FilesTriggerMatch {
pub registered_by_principal: AdminUserId,
}
/// Create payload for a pubsub trigger (v1.1.5). `topic_pattern` is
/// validated (exact / `<prefix>.*` / `*`) before insert.
#[derive(Debug, Clone)]
pub struct CreatePubsubTrigger {
pub script_id: ScriptId,
pub topic_pattern: String,
pub dispatch_mode: TriggerDispatchMode,
pub retry_max_attempts: u32,
pub retry_backoff: BackoffShape,
pub retry_base_ms: u32,
pub registered_by_principal: AdminUserId,
}
/// One match for the dispatcher's "which KV triggers fire on this
/// event" lookup. Carries everything the dispatcher needs to construct
/// the outbox row.
@@ -287,6 +306,13 @@ pub trait TriggerRepo: Send + Sync {
req: CreateFilesTrigger,
) -> Result<Trigger, TriggerRepoError>;
/// v1.1.5. `topic_pattern` is validated before insert.
async fn create_pubsub_trigger(
&self,
app_id: AppId,
req: CreatePubsubTrigger,
) -> Result<Trigger, TriggerRepoError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError>;
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError>;
@@ -675,6 +701,66 @@ impl TriggerRepo for PostgresTriggerRepo {
})
}
async fn create_pubsub_trigger(
&self,
app_id: AppId,
req: CreatePubsubTrigger,
) -> Result<Trigger, TriggerRepoError> {
// Defense-in-depth validation (the admin endpoint validates too).
picloud_shared::validate_topic_pattern(&req.topic_pattern)
.map_err(TriggerRepoError::Invalid)?;
let mut tx = self.pool.begin().await?;
let parent: TriggerRow = sqlx::query_as(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'pubsub', TRUE, $3, $4, $5, $6, $7) \
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at",
)
.bind(app_id.into_inner())
.bind(req.script_id.into_inner())
.bind(req.dispatch_mode.as_str())
.bind(i32::try_from(req.retry_max_attempts).unwrap_or(3))
.bind(req.retry_backoff.as_str())
.bind(i32::try_from(req.retry_base_ms).unwrap_or(1000))
.bind(req.registered_by_principal.into_inner())
.fetch_one(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO pubsub_trigger_details (trigger_id, topic_pattern) VALUES ($1, $2)",
)
.bind(parent.id)
.bind(&req.topic_pattern)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
kind: TriggerKind::Pubsub,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
.unwrap_or(BackoffShape::Exponential),
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(1000),
registered_by_principal: parent.registered_by_principal.into(),
created_at: parent.created_at,
updated_at: parent.updated_at,
details: TriggerDetails::Pubsub {
topic_pattern: req.topic_pattern,
},
})
}
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
let parents: Vec<TriggerRow> = sqlx::query_as(
"SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \
@@ -980,6 +1066,17 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
ops,
}
}
TriggerKind::Pubsub => {
let row: PubsubDetailRow = sqlx::query_as(
"SELECT topic_pattern FROM pubsub_trigger_details WHERE trigger_id = $1",
)
.bind(parent.id)
.fetch_one(pool)
.await?;
TriggerDetails::Pubsub {
topic_pattern: row.topic_pattern,
}
}
};
Ok(Trigger {
@@ -1052,6 +1149,11 @@ struct CronDetailRow {
last_fired_at: Option<DateTime<Utc>>,
}
#[derive(sqlx::FromRow)]
struct PubsubDetailRow {
topic_pattern: String,
}
#[derive(sqlx::FromRow)]
#[allow(clippy::struct_field_names)]
struct DlDetailRow {

View File

@@ -28,7 +28,8 @@ use crate::repo::{ScriptRepository, ScriptRepositoryError};
use crate::trigger_config::{BackoffShape, TriggerConfig};
use crate::trigger_repo::{
CreateCronTrigger, CreateDeadLetterTrigger, CreateDocsTrigger, CreateFilesTrigger,
CreateKvTrigger, Trigger, TriggerDispatchMode, TriggerRepo, TriggerRepoError,
CreateKvTrigger, CreatePubsubTrigger, Trigger, TriggerDispatchMode, TriggerRepo,
TriggerRepoError,
};
#[derive(Clone)]
@@ -57,6 +58,10 @@ pub fn triggers_router(state: TriggersState) -> Router {
.route("/apps/{app_id}/triggers/docs", post(create_docs_trigger))
.route("/apps/{app_id}/triggers/cron", post(create_cron_trigger))
.route("/apps/{app_id}/triggers/files", post(create_files_trigger))
.route(
"/apps/{app_id}/triggers/pubsub",
post(create_pubsub_trigger),
)
.route(
"/apps/{app_id}/triggers/dead_letter",
post(create_dl_trigger),
@@ -349,6 +354,57 @@ async fn create_cron_trigger(
Ok((StatusCode::CREATED, Json(created)))
}
/// v1.1.5 pubsub trigger. `topic_pattern` is validated to be exact /
/// `<prefix>.*` / `*`.
#[derive(Debug, Deserialize)]
pub struct CreatePubsubTriggerRequest {
pub script_id: ScriptId,
pub topic_pattern: String,
#[serde(default = "default_dispatch")]
pub dispatch_mode: TriggerDispatchMode,
#[serde(default)]
pub retry_max_attempts: Option<u32>,
#[serde(default)]
pub retry_backoff: Option<BackoffShape>,
#[serde(default)]
pub retry_base_ms: Option<u32>,
}
async fn create_pubsub_trigger(
State(s): State<TriggersState>,
Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>,
Json(input): Json<CreatePubsubTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
ensure_app_exists(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppManageTriggers(app_id),
)
.await?;
// Validate the topic pattern before touching the script repo so a
// bad pattern fails fast with a clear 422.
picloud_shared::validate_topic_pattern(&input.topic_pattern)
.map_err(TriggersApiError::Invalid)?;
validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;
let req = CreatePubsubTrigger {
script_id: input.script_id,
topic_pattern: input.topic_pattern,
dispatch_mode: input.dispatch_mode,
retry_max_attempts: input
.retry_max_attempts
.unwrap_or(s.config.retry_max_attempts),
retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff),
retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms),
registered_by_principal: principal.user_id,
};
let created = s.triggers.create_pubsub_trigger(app_id, req).await?;
Ok((StatusCode::CREATED, Json(created)))
}
async fn create_files_trigger(
State(s): State<TriggersState>,
Extension(principal): Extension<Principal>,
@@ -542,8 +598,9 @@ mod tests {
use super::*;
use crate::app_repo::{AppLookup, AppRepository};
use crate::trigger_repo::{
CreateCronTrigger, CreateFilesTrigger, DeadLetterTriggerMatch, DocsTriggerMatch,
FilesTriggerMatch, KvTriggerMatch, Trigger, TriggerDetails, TriggerRepo, TriggerRepoError,
CreateCronTrigger, CreateFilesTrigger, CreatePubsubTrigger, DeadLetterTriggerMatch,
DocsTriggerMatch, FilesTriggerMatch, KvTriggerMatch, Trigger, TriggerDetails, TriggerRepo,
TriggerRepoError,
};
use async_trait::async_trait;
use chrono::Utc;
@@ -703,6 +760,33 @@ mod tests {
self.inner.lock().await.insert(id, trigger.clone());
Ok(trigger)
}
async fn create_pubsub_trigger(
&self,
app_id: AppId,
req: CreatePubsubTrigger,
) -> Result<Trigger, TriggerRepoError> {
let now = Utc::now();
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
script_id: req.script_id,
kind: crate::trigger_repo::TriggerKind::Pubsub,
enabled: true,
dispatch_mode: req.dispatch_mode,
retry_max_attempts: req.retry_max_attempts,
retry_backoff: req.retry_backoff,
retry_base_ms: req.retry_base_ms,
registered_by_principal: req.registered_by_principal,
created_at: now,
updated_at: now,
details: TriggerDetails::Pubsub {
topic_pattern: req.topic_pattern,
},
};
self.inner.lock().await.insert(id, trigger.clone());
Ok(trigger)
}
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
Ok(self
.inner