//! `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, GroupId}; use sqlx::PgPool; use uuid::Uuid; use crate::config_resolver::CHAIN_LEVELS_CTE; #[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; /// §11.6 D2: fan out a SHARED-topic publish to every `shared = true` pubsub /// trigger on `owning_group` (the group resolved from the shared-topic /// declaration), inserting one outbox row each stamped with the WRITER /// `ctx.app_id` — so the handler runs under the publishing app, matching the /// M2 shared-collection trigger model. No chain union (the owning group is /// already resolved) and no suppression (shared triggers aren't suppressible). async fn fan_out_shared_publish( &self, ctx: PublishCtx, owning_group: GroupId, 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). // §11 tail: the chain union picks up the app's own pubsub triggers AND // ancestor-group pubsub TEMPLATES (live, no materialization). The outbox // row below stamps the firing `ctx.app_id`, so a template runs under the // publishing app — the inheriting-app boundary. The suppression // anti-join drops an inherited template whose handler the app opts out // of (`$1` = ctx.app_id). let rows: Vec = sqlx::query_as(&format!( "{CHAIN_LEVELS_CTE} \ SELECT t.id, t.script_id, d.topic_pattern \ FROM triggers t \ JOIN pubsub_trigger_details d ON d.trigger_id = t.id \ JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner) \ WHERE t.kind = 'pubsub' AND t.enabled = TRUE AND t.shared = FALSE{ANTIJOIN}", ANTIJOIN = crate::trigger_repo::TRIGGER_SUPPRESSION_ANTIJOIN, )) .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; } insert_pubsub_outbox_row(&mut tx, &ctx, r.id, r.script_id, &event_payload).await?; written += 1; } // Commit once — all rows or none. tx.commit().await?; Ok(written) } async fn fan_out_shared_publish( &self, ctx: PublishCtx, owning_group: GroupId, topic: &str, event_payload: serde_json::Value, ) -> Result { let mut tx = self.pool.begin().await?; // §11.6 D2: only `shared = true` pubsub triggers on the RESOLVED owning // group — the shared-topic namespace boundary (a per-app or non-owning // trigger never matches a shared publish). Topic-pattern match in Rust // like the per-app path. 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.kind = 'pubsub' AND t.enabled = TRUE AND t.shared = TRUE \ AND t.group_id = $1", ) .bind(owning_group.into_inner()) .fetch_all(&mut *tx) .await?; let mut written: u32 = 0; for r in rows { if !topic_matches(&r.topic_pattern, topic) { continue; } insert_pubsub_outbox_row(&mut tx, &ctx, r.id, r.script_id, &event_payload).await?; written += 1; } tx.commit().await?; Ok(written) } } /// Insert one pubsub delivery row, stamped with the writer `ctx.app_id` so the /// handler runs under the publishing app. Shared by the per-app + shared /// fan-outs. async fn insert_pubsub_outbox_row( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, ctx: &PublishCtx, trigger_id: Uuid, script_id: Uuid, event_payload: &serde_json::Value, ) -> Result<(), PubsubRepoError> { 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(trigger_id) .bind(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?; Ok(()) }