Files
PiCloud/crates/manager-core/src/trigger_repo.rs
MechaCat02 834c787ee1 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>
2026-06-03 21:37:06 +02:00

1207 lines
42 KiB
Rust

//! `TriggerRepo` — CRUD over the `triggers` parent + per-kind detail
//! tables. The admin endpoints (commit 4) sit on top of this; the
//! dispatcher (commit 5) reads `list_matching_*` to fan out events to
//! handler scripts.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{
AdminUserId, AppId, DocsEventOp, FilesEventOp, KvEventOp, ScriptId, TriggerId,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::trigger_config::BackoffShape;
#[derive(Debug, thiserror::Error)]
pub enum TriggerRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("trigger not found: {0}")]
NotFound(TriggerId),
#[error("invalid trigger payload: {0}")]
Invalid(String),
}
/// Parent-table row plus the per-kind detail merged in. Serialized
/// back to admin clients via the JSON API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trigger {
pub id: TriggerId,
pub app_id: AppId,
pub script_id: ScriptId,
pub kind: TriggerKind,
pub enabled: bool,
pub dispatch_mode: TriggerDispatchMode,
pub retry_max_attempts: u32,
pub retry_backoff: BackoffShape,
pub retry_base_ms: u32,
pub registered_by_principal: AdminUserId,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub details: TriggerDetails,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TriggerKind {
Kv,
Docs,
DeadLetter,
/// v1.1.4.
Cron,
/// v1.1.5.
Files,
/// v1.1.5.
Pubsub,
}
impl TriggerKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Kv => "kv",
Self::Docs => "docs",
Self::DeadLetter => "dead_letter",
Self::Cron => "cron",
Self::Files => "files",
Self::Pubsub => "pubsub",
}
}
#[must_use]
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"kv" => Some(Self::Kv),
"docs" => Some(Self::Docs),
"dead_letter" => Some(Self::DeadLetter),
"cron" => Some(Self::Cron),
"files" => Some(Self::Files),
"pubsub" => Some(Self::Pubsub),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TriggerDispatchMode {
Sync,
Async,
}
impl TriggerDispatchMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Sync => "sync",
Self::Async => "async",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TriggerDetails {
Kv {
collection_glob: String,
ops: Vec<KvEventOp>,
},
Docs {
collection_glob: String,
ops: Vec<DocsEventOp>,
},
DeadLetter {
#[serde(default, skip_serializing_if = "Option::is_none")]
source_filter: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
trigger_id_filter: Option<TriggerId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
script_id_filter: Option<ScriptId>,
},
/// v1.1.4. The 6-field cron schedule + IANA timezone the trigger
/// fires on, plus the last enqueue time (for dashboard display).
Cron {
schedule: String,
timezone: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_fired_at: Option<DateTime<Utc>>,
},
/// v1.1.5. Same shape as KV/docs: a collection glob + op subset.
Files {
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
/// layer (uses `TriggerConfig::from_env` to fill retry settings if
/// the request omitted them — keeps the row auditable).
#[derive(Debug, Clone)]
pub struct CreateKvTrigger {
pub script_id: ScriptId,
pub collection_glob: String,
pub ops: Vec<KvEventOp>,
pub dispatch_mode: TriggerDispatchMode,
pub retry_max_attempts: u32,
pub retry_backoff: BackoffShape,
pub retry_base_ms: u32,
pub registered_by_principal: AdminUserId,
}
/// Create payload for a docs trigger (v1.1.2). Same shape as KV with
/// `DocsEventOp` ops instead of `KvEventOp`.
#[derive(Debug, Clone)]
pub struct CreateDocsTrigger {
pub script_id: ScriptId,
pub collection_glob: String,
pub ops: Vec<DocsEventOp>,
pub dispatch_mode: TriggerDispatchMode,
pub retry_max_attempts: u32,
pub retry_backoff: BackoffShape,
pub retry_base_ms: u32,
pub registered_by_principal: AdminUserId,
}
#[derive(Debug, Clone)]
pub struct CreateDeadLetterTrigger {
pub script_id: ScriptId,
pub source_filter: Option<String>,
pub trigger_id_filter: Option<TriggerId>,
pub script_id_filter: Option<ScriptId>,
pub registered_by_principal: AdminUserId,
}
/// Create payload for a cron trigger (v1.1.4). `schedule` is a 6-field
/// cron expression and `timezone` an IANA name; both are validated
/// (by the admin endpoint and defensively by the repo) before insert.
#[derive(Debug, Clone)]
pub struct CreateCronTrigger {
pub script_id: ScriptId,
pub schedule: String,
pub timezone: String,
pub dispatch_mode: TriggerDispatchMode,
pub retry_max_attempts: u32,
pub retry_backoff: BackoffShape,
pub retry_base_ms: u32,
pub registered_by_principal: AdminUserId,
}
/// Create payload for a files trigger (v1.1.5). Same shape as KV with
/// `FilesEventOp` ops.
#[derive(Debug, Clone)]
pub struct CreateFilesTrigger {
pub script_id: ScriptId,
pub collection_glob: String,
pub ops: Vec<FilesEventOp>,
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 files trigger fan-out lookup
/// (v1.1.5). Same shape as `KvTriggerMatch`.
#[derive(Debug, Clone)]
pub struct FilesTriggerMatch {
pub trigger_id: TriggerId,
pub script_id: ScriptId,
pub dispatch_mode: TriggerDispatchMode,
pub retry_max_attempts: u32,
pub retry_backoff: BackoffShape,
pub retry_base_ms: u32,
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.
#[derive(Debug, Clone)]
pub struct KvTriggerMatch {
pub trigger_id: TriggerId,
pub script_id: ScriptId,
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 docs trigger fan-out lookup (v1.1.2).
/// Same shape as `KvTriggerMatch`.
#[derive(Debug, Clone)]
pub struct DocsTriggerMatch {
pub trigger_id: TriggerId,
pub script_id: ScriptId,
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 dead-letter triggers fire
/// on this dead-letter row" lookup.
#[derive(Debug, Clone)]
pub struct DeadLetterTriggerMatch {
pub trigger_id: TriggerId,
pub script_id: ScriptId,
pub dispatch_mode: TriggerDispatchMode,
pub registered_by_principal: AdminUserId,
}
#[async_trait]
pub trait TriggerRepo: Send + Sync {
async fn create_kv_trigger(
&self,
app_id: AppId,
req: CreateKvTrigger,
) -> Result<Trigger, TriggerRepoError>;
/// v1.1.2.
async fn create_docs_trigger(
&self,
app_id: AppId,
req: CreateDocsTrigger,
) -> Result<Trigger, TriggerRepoError>;
async fn create_dead_letter_trigger(
&self,
app_id: AppId,
req: CreateDeadLetterTrigger,
) -> Result<Trigger, TriggerRepoError>;
/// v1.1.4. `schedule` + `timezone` are validated before insert; an
/// invalid expression or unknown IANA name returns
/// `TriggerRepoError::Invalid`.
async fn create_cron_trigger(
&self,
app_id: AppId,
req: CreateCronTrigger,
) -> Result<Trigger, TriggerRepoError>;
/// v1.1.5.
async fn create_files_trigger(
&self,
app_id: AppId,
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>;
async fn delete(&self, id: TriggerId) -> Result<bool, TriggerRepoError>;
/// Dispatcher hot path: find every enabled KV trigger in `app_id`
/// whose `collection_glob` matches `collection` and whose `ops`
/// covers `op`. Glob matching done in Rust (the column is plain
/// TEXT, the matcher applies "*"/"prefix:*" semantics).
async fn list_matching_kv(
&self,
app_id: AppId,
collection: &str,
op: KvEventOp,
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError>;
/// Dispatcher hot path for docs fan-out (v1.1.2). Mirrors the KV
/// fan-out logic: pull every enabled docs trigger, filter glob +
/// ops in Rust (empty ops array means "any op").
async fn list_matching_docs(
&self,
app_id: AppId,
collection: &str,
op: DocsEventOp,
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError>;
/// Dispatcher hot path for files fan-out (v1.1.5). Mirrors the KV
/// fan-out logic: pull every enabled files trigger, filter glob +
/// ops in Rust (empty ops array means "any op").
async fn list_matching_files(
&self,
app_id: AppId,
collection: &str,
op: FilesEventOp,
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError>;
/// Dispatcher hot path for dead-letter fan-out. Filters: source
/// (or any-source), originating trigger_id (or any), originating
/// script_id (or any). Each filter is "match OR is_null".
async fn list_matching_dead_letter(
&self,
app_id: AppId,
source: &str,
trigger_id: Option<TriggerId>,
script_id: Option<ScriptId>,
) -> Result<Vec<DeadLetterTriggerMatch>, TriggerRepoError>;
}
// ----------------------------------------------------------------------------
// Postgres impl
// ----------------------------------------------------------------------------
pub struct PostgresTriggerRepo {
pool: PgPool,
}
impl PostgresTriggerRepo {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl TriggerRepo for PostgresTriggerRepo {
async fn create_kv_trigger(
&self,
app_id: AppId,
req: CreateKvTrigger,
) -> Result<Trigger, TriggerRepoError> {
if req.collection_glob.is_empty() {
return Err(TriggerRepoError::Invalid(
"collection_glob must not be empty".into(),
));
}
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, 'kv', 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?;
let ops_str: Vec<String> = req.ops.iter().map(|o| o.as_str().to_string()).collect();
sqlx::query(
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
VALUES ($1, $2, $3)",
)
.bind(parent.id)
.bind(&req.collection_glob)
.bind(&ops_str)
.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::Kv,
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::Kv {
collection_glob: req.collection_glob,
ops: req.ops,
},
})
}
async fn create_docs_trigger(
&self,
app_id: AppId,
req: CreateDocsTrigger,
) -> Result<Trigger, TriggerRepoError> {
if req.collection_glob.is_empty() {
return Err(TriggerRepoError::Invalid(
"collection_glob must not be empty".into(),
));
}
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, 'docs', 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?;
let ops_str: Vec<String> = req.ops.iter().map(|o| o.as_str().to_string()).collect();
sqlx::query(
"INSERT INTO docs_trigger_details (trigger_id, collection_glob, ops) \
VALUES ($1, $2, $3)",
)
.bind(parent.id)
.bind(&req.collection_glob)
.bind(&ops_str)
.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::Docs,
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::Docs {
collection_glob: req.collection_glob,
ops: req.ops,
},
})
}
async fn create_dead_letter_trigger(
&self,
app_id: AppId,
req: CreateDeadLetterTrigger,
) -> Result<Trigger, TriggerRepoError> {
let mut tx = self.pool.begin().await?;
// Dead-letter triggers force max_attempts=1 (design notes §4
// recursion-stop). Backoff/base_ms irrelevant but the columns
// are NOT NULL — store sensible values.
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, 'dead_letter', TRUE, 'async', 1, 'constant', 0, $3) \
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.registered_by_principal.into_inner())
.fetch_one(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO dead_letter_trigger_details \
(trigger_id, source_filter, trigger_id_filter, script_id_filter) \
VALUES ($1, $2, $3, $4)",
)
.bind(parent.id)
.bind(req.source_filter.as_deref())
.bind(req.trigger_id_filter.map(TriggerId::into_inner))
.bind(req.script_id_filter.map(ScriptId::into_inner))
.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::DeadLetter,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(1),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
.unwrap_or(BackoffShape::Constant),
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(0),
registered_by_principal: parent.registered_by_principal.into(),
created_at: parent.created_at,
updated_at: parent.updated_at,
details: TriggerDetails::DeadLetter {
source_filter: req.source_filter,
trigger_id_filter: req.trigger_id_filter,
script_id_filter: req.script_id_filter,
},
})
}
async fn create_cron_trigger(
&self,
app_id: AppId,
req: CreateCronTrigger,
) -> Result<Trigger, TriggerRepoError> {
// Defense-in-depth validation (the admin endpoint validates too).
crate::cron_scheduler::validate_schedule(&req.schedule)
.map_err(|e| TriggerRepoError::Invalid(format!("invalid cron schedule: {e}")))?;
crate::cron_scheduler::validate_timezone(&req.timezone)
.map_err(|e| TriggerRepoError::Invalid(format!("invalid timezone: {e}")))?;
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, 'cron', 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 cron_trigger_details (trigger_id, schedule, timezone) \
VALUES ($1, $2, $3)",
)
.bind(parent.id)
.bind(&req.schedule)
.bind(&req.timezone)
.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::Cron,
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::Cron {
schedule: req.schedule,
timezone: req.timezone,
last_fired_at: None,
},
})
}
async fn create_files_trigger(
&self,
app_id: AppId,
req: CreateFilesTrigger,
) -> Result<Trigger, TriggerRepoError> {
if req.collection_glob.is_empty() {
return Err(TriggerRepoError::Invalid(
"collection_glob must not be empty".into(),
));
}
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, 'files', 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?;
let ops_str: Vec<String> = req.ops.iter().map(|o| o.as_str().to_string()).collect();
sqlx::query(
"INSERT INTO files_trigger_details (trigger_id, collection_glob, ops) \
VALUES ($1, $2, $3)",
)
.bind(parent.id)
.bind(&req.collection_glob)
.bind(&ops_str)
.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::Files,
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::Files {
collection_glob: req.collection_glob,
ops: req.ops,
},
})
}
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, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at \
FROM triggers WHERE app_id = $1 ORDER BY created_at DESC",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
let mut out = Vec::with_capacity(parents.len());
for p in parents {
out.push(hydrate_one(&self.pool, p).await?);
}
Ok(out)
}
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError> {
let parent: Option<TriggerRow> = sqlx::query_as(
"SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at \
FROM triggers WHERE id = $1",
)
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
match parent {
Some(p) => Ok(Some(hydrate_one(&self.pool, p).await?)),
None => Ok(None),
}
}
async fn delete(&self, id: TriggerId) -> Result<bool, TriggerRepoError> {
// ON DELETE CASCADE on the detail tables takes care of them.
let res = sqlx::query("DELETE FROM triggers WHERE id = $1")
.bind(id.into_inner())
.execute(&self.pool)
.await?;
Ok(res.rows_affected() > 0)
}
async fn list_matching_kv(
&self,
app_id: AppId,
collection: &str,
op: KvEventOp,
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError> {
// Fetch all enabled KV triggers for the app — glob matching
// happens in Rust so we don't have to teach the query about
// `*` and `prefix:*`. Sets are tiny in practice (one app's
// worth of triggers, usually a handful).
let rows: Vec<KvMatchRow> = sqlx::query_as(
"SELECT t.id, t.script_id, t.dispatch_mode, \
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
t.registered_by_principal, \
d.collection_glob, d.ops \
FROM triggers t \
JOIN kv_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'kv' AND t.enabled = TRUE",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
let op_str = op.as_str();
let mut out = Vec::new();
for r in rows {
if !collection_matches(&r.collection_glob, collection) {
continue;
}
let any_op = r.ops.is_empty();
if !any_op && !r.ops.iter().any(|o| o == op_str) {
continue;
}
out.push(KvTriggerMatch {
trigger_id: r.id.into(),
script_id: r.script_id.into(),
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
retry_max_attempts: u32::try_from(r.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&r.retry_backoff)
.unwrap_or(BackoffShape::Exponential),
retry_base_ms: u32::try_from(r.retry_base_ms).unwrap_or(1000),
registered_by_principal: r.registered_by_principal.into(),
});
}
Ok(out)
}
async fn list_matching_docs(
&self,
app_id: AppId,
collection: &str,
op: DocsEventOp,
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError> {
// Mirrors list_matching_kv: pull every enabled docs trigger,
// filter glob + ops in Rust. **Critical**: do NOT push the
// ops check into SQL (`WHERE $op = ANY(ops)`) — that would
// exclude rows with `ops = '{}'` from the results, breaking
// the empty-array-means-any-op semantic.
let rows: Vec<KvMatchRow> = sqlx::query_as(
"SELECT t.id, t.script_id, t.dispatch_mode, \
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
t.registered_by_principal, \
d.collection_glob, d.ops \
FROM triggers t \
JOIN docs_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'docs' AND t.enabled = TRUE",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
let op_str = op.as_str();
let mut out = Vec::new();
for r in rows {
if !collection_matches(&r.collection_glob, collection) {
continue;
}
let any_op = r.ops.is_empty();
if !any_op && !r.ops.iter().any(|o| o == op_str) {
continue;
}
out.push(DocsTriggerMatch {
trigger_id: r.id.into(),
script_id: r.script_id.into(),
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
retry_max_attempts: u32::try_from(r.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&r.retry_backoff)
.unwrap_or(BackoffShape::Exponential),
retry_base_ms: u32::try_from(r.retry_base_ms).unwrap_or(1000),
registered_by_principal: r.registered_by_principal.into(),
});
}
Ok(out)
}
async fn list_matching_files(
&self,
app_id: AppId,
collection: &str,
op: FilesEventOp,
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
// Mirrors list_matching_kv: pull every enabled files trigger,
// filter glob + ops in Rust (empty ops array means "any op").
let rows: Vec<KvMatchRow> = sqlx::query_as(
"SELECT t.id, t.script_id, t.dispatch_mode, \
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
t.registered_by_principal, \
d.collection_glob, d.ops \
FROM triggers t \
JOIN files_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'files' AND t.enabled = TRUE",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
let op_str = op.as_str();
let mut out = Vec::new();
for r in rows {
if !collection_matches(&r.collection_glob, collection) {
continue;
}
let any_op = r.ops.is_empty();
if !any_op && !r.ops.iter().any(|o| o == op_str) {
continue;
}
out.push(FilesTriggerMatch {
trigger_id: r.id.into(),
script_id: r.script_id.into(),
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
retry_max_attempts: u32::try_from(r.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&r.retry_backoff)
.unwrap_or(BackoffShape::Exponential),
retry_base_ms: u32::try_from(r.retry_base_ms).unwrap_or(1000),
registered_by_principal: r.registered_by_principal.into(),
});
}
Ok(out)
}
async fn list_matching_dead_letter(
&self,
app_id: AppId,
source: &str,
trigger_id: Option<TriggerId>,
script_id: Option<ScriptId>,
) -> Result<Vec<DeadLetterTriggerMatch>, TriggerRepoError> {
let rows: Vec<DlMatchRow> = sqlx::query_as(
"SELECT t.id, t.script_id, t.dispatch_mode, t.registered_by_principal, \
d.source_filter, d.trigger_id_filter, d.script_id_filter \
FROM triggers t \
JOIN dead_letter_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'dead_letter' AND t.enabled = TRUE \
AND (d.source_filter IS NULL OR d.source_filter = $2) \
AND (d.trigger_id_filter IS NULL OR d.trigger_id_filter = $3) \
AND (d.script_id_filter IS NULL OR d.script_id_filter = $4)",
)
.bind(app_id.into_inner())
.bind(source)
.bind(trigger_id.map(TriggerId::into_inner))
.bind(script_id.map(ScriptId::into_inner))
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| DeadLetterTriggerMatch {
trigger_id: r.id.into(),
script_id: r.script_id.into(),
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
registered_by_principal: r.registered_by_principal.into(),
})
.collect())
}
}
#[allow(clippy::too_many_lines)]
async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, TriggerRepoError> {
let kind = TriggerKind::from_wire(&parent.kind).ok_or_else(|| {
TriggerRepoError::Invalid(format!("unknown trigger kind {}", parent.kind))
})?;
let details = match kind {
TriggerKind::Kv => {
let row: KvDetailRow = sqlx::query_as(
"SELECT collection_glob, ops FROM kv_trigger_details WHERE trigger_id = $1",
)
.bind(parent.id)
.fetch_one(pool)
.await?;
let ops = row
.ops
.iter()
.filter_map(|s| KvEventOp::from_wire(s))
.collect();
TriggerDetails::Kv {
collection_glob: row.collection_glob,
ops,
}
}
TriggerKind::Docs => {
let row: KvDetailRow = sqlx::query_as(
"SELECT collection_glob, ops FROM docs_trigger_details WHERE trigger_id = $1",
)
.bind(parent.id)
.fetch_one(pool)
.await?;
let ops = row
.ops
.iter()
.filter_map(|s| DocsEventOp::from_wire(s))
.collect();
TriggerDetails::Docs {
collection_glob: row.collection_glob,
ops,
}
}
TriggerKind::DeadLetter => {
let row: DlDetailRow = sqlx::query_as(
"SELECT source_filter, trigger_id_filter, script_id_filter \
FROM dead_letter_trigger_details WHERE trigger_id = $1",
)
.bind(parent.id)
.fetch_one(pool)
.await?;
TriggerDetails::DeadLetter {
source_filter: row.source_filter,
trigger_id_filter: row.trigger_id_filter.map(Into::into),
script_id_filter: row.script_id_filter.map(Into::into),
}
}
TriggerKind::Cron => {
let row: CronDetailRow = sqlx::query_as(
"SELECT schedule, timezone, last_fired_at \
FROM cron_trigger_details WHERE trigger_id = $1",
)
.bind(parent.id)
.fetch_one(pool)
.await?;
TriggerDetails::Cron {
schedule: row.schedule,
timezone: row.timezone,
last_fired_at: row.last_fired_at,
}
}
TriggerKind::Files => {
let row: KvDetailRow = sqlx::query_as(
"SELECT collection_glob, ops FROM files_trigger_details WHERE trigger_id = $1",
)
.bind(parent.id)
.fetch_one(pool)
.await?;
let ops = row
.ops
.iter()
.filter_map(|s| FilesEventOp::from_wire(s))
.collect();
TriggerDetails::Files {
collection_glob: row.collection_glob,
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 {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
kind,
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,
})
}
fn dispatch_from_str(s: &str) -> TriggerDispatchMode {
match s {
"sync" => TriggerDispatchMode::Sync,
_ => TriggerDispatchMode::Async,
}
}
/// Match a `collection_glob` against an actual `collection` name.
/// Supported forms (in priority order):
/// - `"*"` → matches every collection
/// - `"foo*"` → prefix match (anything starting with "foo")
/// - `"foo"` → exact match
#[must_use]
pub fn collection_matches(glob: &str, collection: &str) -> bool {
if glob == "*" {
return true;
}
if let Some(prefix) = glob.strip_suffix('*') {
return collection.starts_with(prefix);
}
glob == collection
}
#[derive(sqlx::FromRow)]
struct TriggerRow {
id: Uuid,
app_id: Uuid,
script_id: Uuid,
kind: String,
enabled: bool,
dispatch_mode: String,
retry_max_attempts: i32,
retry_backoff: String,
retry_base_ms: i32,
registered_by_principal: Uuid,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct KvDetailRow {
collection_glob: String,
ops: Vec<String>,
}
#[derive(sqlx::FromRow)]
struct CronDetailRow {
schedule: String,
timezone: String,
last_fired_at: Option<DateTime<Utc>>,
}
#[derive(sqlx::FromRow)]
struct PubsubDetailRow {
topic_pattern: String,
}
#[derive(sqlx::FromRow)]
#[allow(clippy::struct_field_names)]
struct DlDetailRow {
source_filter: Option<String>,
trigger_id_filter: Option<Uuid>,
script_id_filter: Option<Uuid>,
}
#[derive(sqlx::FromRow)]
struct KvMatchRow {
id: Uuid,
script_id: Uuid,
dispatch_mode: String,
retry_max_attempts: i32,
retry_backoff: String,
retry_base_ms: i32,
registered_by_principal: Uuid,
collection_glob: String,
ops: Vec<String>,
}
#[derive(sqlx::FromRow)]
struct DlMatchRow {
id: Uuid,
script_id: Uuid,
dispatch_mode: String,
registered_by_principal: Uuid,
#[allow(dead_code)]
source_filter: Option<String>,
#[allow(dead_code)]
trigger_id_filter: Option<Uuid>,
#[allow(dead_code)]
script_id_filter: Option<Uuid>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collection_matcher_handles_star_prefix_exact() {
assert!(collection_matches("*", "widgets"));
assert!(collection_matches("*", ""));
assert!(collection_matches("users:*", "users:1"));
assert!(collection_matches("users:*", "users:"));
assert!(!collection_matches("users:*", "orgs:1"));
assert!(collection_matches("widgets", "widgets"));
assert!(!collection_matches("widgets", "Widgets"));
}
}