//! `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, 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; } 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 { 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 = 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) } }