From ef5930910b36798c860decef77f5ef87d8006f75 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 2 Jun 2026 19:55:27 +0200 Subject: [PATCH] feat(v1.1.2-docs): triggers framework + dispatcher + emitter extended for docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs trigger kind hangs off the same Layout-E shape that v1.1.1 established for KV: a parent triggers row + a docs_trigger_details row (collection_glob TEXT + ops TEXT[]) with the empty-array = any-op semantic preserved. - trigger_repo.rs adds TriggerKind::Docs + TriggerDetails::Docs + CreateDocsTrigger + DocsTriggerMatch + PostgresTriggerRepo implementations of create_docs_trigger and list_matching_docs. list_matching_docs mirrors KV's Rust-side filter (does NOT push ops membership into SQL — that would exclude empty-ops rows). - outbox_repo.rs adds OutboxSourceKind::Docs to the enum + wire form. - dispatcher.rs's generic Kv | DeadLetter routing arm extends to Kv | DeadLetter | Docs. No kind-specific logic needed — the resolve_trigger + build_exec_request path is already abstract. - outbox_event_emitter.rs gains a "docs" arm in the emit match plus emit_docs which builds TriggerEvent::Docs (carrying data + prev_data) and fans out across matching triggers. - triggers_api.rs adds CreateDocsTriggerRequest + create_docs_trigger + the POST /api/v1/admin/apps/{id}/triggers/docs route, all guarded by Capability::AppManageTriggers (same as KV). 3 new triggers_api unit tests covering happy path, empty-glob rejection, and capability denial. All existing trigger-related tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/manager-core/src/dispatcher.rs | 2 +- .../manager-core/src/outbox_event_emitter.rs | 56 +++++- crates/manager-core/src/outbox_repo.rs | 4 + crates/manager-core/src/trigger_repo.rs | 183 ++++++++++++++++- crates/manager-core/src/triggers_api.rs | 189 +++++++++++++++++- 5 files changed, 425 insertions(+), 9 deletions(-) diff --git a/crates/manager-core/src/dispatcher.rs b/crates/manager-core/src/dispatcher.rs index 2571f75..d6136c1 100644 --- a/crates/manager-core/src/dispatcher.rs +++ b/crates/manager-core/src/dispatcher.rs @@ -163,7 +163,7 @@ impl Dispatcher { return Ok(()); } }, - OutboxSourceKind::Kv | OutboxSourceKind::DeadLetter => { + OutboxSourceKind::Kv | OutboxSourceKind::Docs | OutboxSourceKind::DeadLetter => { let resolved = self.resolve_trigger(&row).await?; let req = match self.build_exec_request(&row, &resolved).await { Ok(req) => req, diff --git a/crates/manager-core/src/outbox_event_emitter.rs b/crates/manager-core/src/outbox_event_emitter.rs index 176c10e..406e1c3 100644 --- a/crates/manager-core/src/outbox_event_emitter.rs +++ b/crates/manager-core/src/outbox_event_emitter.rs @@ -19,7 +19,7 @@ use std::sync::Arc; use async_trait::async_trait; use picloud_shared::{ - EmitError, KvEventOp, SdkCallCx, ServiceEvent, ServiceEventEmitter, TriggerEvent, + DocsEventOp, EmitError, KvEventOp, SdkCallCx, ServiceEvent, ServiceEventEmitter, TriggerEvent, }; use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind}; @@ -42,6 +42,7 @@ impl ServiceEventEmitter for OutboxEventEmitter { async fn emit(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> { match event.source { "kv" => self.emit_kv(cx, event).await, + "docs" => self.emit_docs(cx, event).await, // Future sources land here. For now, silently drop — the // SDK calls `events.emit(...)` unconditionally for forward // compat, so swallowing without an error is correct. @@ -100,4 +101,57 @@ impl OutboxEventEmitter { } Ok(()) } + + /// v1.1.2. Mirrors `emit_kv` — fan out a docs mutation across + /// matching docs triggers + write one outbox row each. The + /// `prev_data` change-data-capture surface is preserved from the + /// `ServiceEvent.old_payload` field (set by `DocsServiceImpl` on + /// update and delete; `None` for create). + async fn emit_docs(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> { + let Some(op) = DocsEventOp::from_wire(event.op) else { + return Ok(()); + }; + let Some(collection) = event.collection.clone() else { + return Ok(()); + }; + let id = event.key.clone().unwrap_or_default(); + + let matches = self + .triggers + .list_matching_docs(cx.app_id, &collection, op) + .await + .map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?; + + if matches.is_empty() { + return Ok(()); + } + + let trigger_event = TriggerEvent::Docs { + op, + collection, + id, + data: event.payload.clone(), + prev_data: event.old_payload.clone(), + }; + let payload = serde_json::to_value(&trigger_event) + .map_err(|e| EmitError::Rejected(format!("event serialize: {e}")))?; + + for m in matches { + self.outbox + .insert(NewOutboxRow { + app_id: cx.app_id, + source_kind: OutboxSourceKind::Docs, + trigger_id: Some(m.trigger_id), + script_id: Some(m.script_id), + reply_to: None, + payload: payload.clone(), + origin_principal: cx.principal.as_ref().map(|p| p.user_id), + trigger_depth: cx.trigger_depth.saturating_add(1), + root_execution_id: Some(cx.root_execution_id), + }) + .await + .map_err(|e| EmitError::Unavailable(format!("outbox insert: {e}")))?; + } + Ok(()) + } } diff --git a/crates/manager-core/src/outbox_repo.rs b/crates/manager-core/src/outbox_repo.rs index 926aba6..996b50d 100644 --- a/crates/manager-core/src/outbox_repo.rs +++ b/crates/manager-core/src/outbox_repo.rs @@ -22,6 +22,8 @@ pub enum OutboxRepoError { pub enum OutboxSourceKind { Http, Kv, + /// v1.1.2. + Docs, DeadLetter, } @@ -31,6 +33,7 @@ impl OutboxSourceKind { match self { Self::Http => "http", Self::Kv => "kv", + Self::Docs => "docs", Self::DeadLetter => "dead_letter", } } @@ -40,6 +43,7 @@ impl OutboxSourceKind { match s { "http" => Some(Self::Http), "kv" => Some(Self::Kv), + "docs" => Some(Self::Docs), "dead_letter" => Some(Self::DeadLetter), _ => None, } diff --git a/crates/manager-core/src/trigger_repo.rs b/crates/manager-core/src/trigger_repo.rs index 15d6937..c2aad45 100644 --- a/crates/manager-core/src/trigger_repo.rs +++ b/crates/manager-core/src/trigger_repo.rs @@ -5,7 +5,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use picloud_shared::{AdminUserId, AppId, KvEventOp, ScriptId, TriggerId}; +use picloud_shared::{AdminUserId, AppId, DocsEventOp, KvEventOp, ScriptId, TriggerId}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; @@ -47,6 +47,7 @@ pub struct Trigger { #[serde(rename_all = "snake_case")] pub enum TriggerKind { Kv, + Docs, DeadLetter, } @@ -55,6 +56,7 @@ impl TriggerKind { pub const fn as_str(self) -> &'static str { match self { Self::Kv => "kv", + Self::Docs => "docs", Self::DeadLetter => "dead_letter", } } @@ -63,6 +65,7 @@ impl TriggerKind { pub fn from_wire(s: &str) -> Option { match s { "kv" => Some(Self::Kv), + "docs" => Some(Self::Docs), "dead_letter" => Some(Self::DeadLetter), _ => None, } @@ -93,6 +96,10 @@ pub enum TriggerDetails { collection_glob: String, ops: Vec, }, + Docs { + collection_glob: String, + ops: Vec, + }, DeadLetter { #[serde(default, skip_serializing_if = "Option::is_none")] source_filter: Option, @@ -118,6 +125,20 @@ pub struct CreateKvTrigger { 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, + 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, @@ -141,6 +162,19 @@ pub struct KvTriggerMatch { 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)] @@ -159,6 +193,13 @@ pub trait TriggerRepo: Send + Sync { req: CreateKvTrigger, ) -> Result; + /// v1.1.2. + async fn create_docs_trigger( + &self, + app_id: AppId, + req: CreateDocsTrigger, + ) -> Result; + async fn create_dead_letter_trigger( &self, app_id: AppId, @@ -182,6 +223,16 @@ pub trait TriggerRepo: Send + Sync { op: KvEventOp, ) -> Result, 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, 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". @@ -276,6 +327,71 @@ impl TriggerRepo for PostgresTriggerRepo { }) } + async fn create_docs_trigger( + &self, + app_id: AppId, + req: CreateDocsTrigger, + ) -> Result { + 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 = 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, @@ -427,6 +543,54 @@ impl TriggerRepo for PostgresTriggerRepo { Ok(out) } + async fn list_matching_docs( + &self, + app_id: AppId, + collection: &str, + op: DocsEventOp, + ) -> Result, 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 = 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_dead_letter( &self, app_id: AppId, @@ -486,6 +650,23 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result { + 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 \ diff --git a/crates/manager-core/src/triggers_api.rs b/crates/manager-core/src/triggers_api.rs index 1fafe54..b73fdb2 100644 --- a/crates/manager-core/src/triggers_api.rs +++ b/crates/manager-core/src/triggers_api.rs @@ -16,7 +16,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{delete, get, post}; use axum::{Extension, Router}; -use picloud_shared::{AppId, KvEventOp, Principal, ScriptId, TriggerId}; +use picloud_shared::{AppId, DocsEventOp, KvEventOp, Principal, ScriptId, TriggerId}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -24,8 +24,8 @@ use crate::app_repo::AppRepository; use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; use crate::trigger_config::{BackoffShape, TriggerConfig}; use crate::trigger_repo::{ - CreateDeadLetterTrigger, CreateKvTrigger, Trigger, TriggerDispatchMode, TriggerRepo, - TriggerRepoError, + CreateDeadLetterTrigger, CreateDocsTrigger, CreateKvTrigger, Trigger, TriggerDispatchMode, + TriggerRepo, TriggerRepoError, }; #[derive(Clone)] @@ -46,6 +46,7 @@ pub fn triggers_router(state: TriggersState) -> Router { get(list_triggers).delete(noop_405), ) .route("/apps/{app_id}/triggers/kv", post(create_kv_trigger)) + .route("/apps/{app_id}/triggers/docs", post(create_docs_trigger)) .route( "/apps/{app_id}/triggers/dead_letter", post(create_dl_trigger), @@ -90,6 +91,25 @@ const fn default_dispatch() -> TriggerDispatchMode { TriggerDispatchMode::Async } +/// v1.1.2. Same shape as `CreateKvTriggerRequest`; `ops` uses +/// `DocsEventOp` (`create` / `update` / `delete`) instead of +/// `KvEventOp` (`insert` / `update` / `delete`). +#[derive(Debug, Deserialize)] +pub struct CreateDocsTriggerRequest { + pub script_id: ScriptId, + pub collection_glob: String, + #[serde(default)] + pub ops: Vec, + #[serde(default = "default_dispatch")] + pub dispatch_mode: TriggerDispatchMode, + #[serde(default)] + pub retry_max_attempts: Option, + #[serde(default)] + pub retry_backoff: Option, + #[serde(default)] + pub retry_base_ms: Option, +} + #[derive(Debug, Deserialize)] pub struct CreateDeadLetterTriggerRequest { pub script_id: ScriptId, @@ -162,6 +182,42 @@ async fn create_kv_trigger( Ok((StatusCode::CREATED, Json(created))) } +async fn create_docs_trigger( + State(s): State, + Extension(principal): Extension, + Path(app_id): Path, + Json(input): Json, +) -> Result<(StatusCode, Json), TriggersApiError> { + ensure_app_exists(&*s.apps, app_id).await?; + require( + s.authz.as_ref(), + &principal, + Capability::AppManageTriggers(app_id), + ) + .await?; + + if input.collection_glob.trim().is_empty() { + return Err(TriggersApiError::Invalid( + "collection_glob must not be empty".into(), + )); + } + + let req = CreateDocsTrigger { + script_id: input.script_id, + collection_glob: input.collection_glob, + ops: input.ops, + 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_docs_trigger(app_id, req).await?; + Ok((StatusCode::CREATED, Json(created))) +} + async fn create_dl_trigger( State(s): State, Extension(principal): Extension, @@ -317,12 +373,14 @@ mod tests { use super::*; use crate::app_repo::{AppLookup, AppRepository}; use crate::trigger_repo::{ - DeadLetterTriggerMatch, KvTriggerMatch, Trigger, TriggerDetails, TriggerRepo, - TriggerRepoError, + DeadLetterTriggerMatch, DocsTriggerMatch, KvTriggerMatch, Trigger, TriggerDetails, + TriggerRepo, TriggerRepoError, }; use async_trait::async_trait; use chrono::Utc; - use picloud_shared::{AdminUserId, App, AppRole, KvEventOp, ScriptId, TriggerId, UserId}; + use picloud_shared::{ + AdminUserId, App, AppRole, DocsEventOp, KvEventOp, ScriptId, TriggerId, UserId, + }; use std::collections::HashMap; use tokio::sync::Mutex; @@ -361,6 +419,34 @@ mod tests { self.inner.lock().await.insert(id, trigger.clone()); Ok(trigger) } + async fn create_docs_trigger( + &self, + app_id: AppId, + req: CreateDocsTrigger, + ) -> Result { + let now = Utc::now(); + let id = TriggerId::new(); + let trigger = Trigger { + id, + app_id, + script_id: req.script_id, + kind: crate::trigger_repo::TriggerKind::Docs, + 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::Docs { + collection_glob: req.collection_glob, + ops: req.ops, + }, + }; + self.inner.lock().await.insert(id, trigger.clone()); + Ok(trigger) + } async fn create_dead_letter_trigger( &self, app_id: AppId, @@ -414,6 +500,14 @@ mod tests { ) -> Result, TriggerRepoError> { Ok(vec![]) } + async fn list_matching_docs( + &self, + _app_id: AppId, + _collection: &str, + _op: DocsEventOp, + ) -> Result, TriggerRepoError> { + Ok(vec![]) + } async fn list_matching_dead_letter( &self, _app_id: AppId, @@ -672,6 +766,89 @@ mod tests { assert!(matches!(err, TriggersApiError::Invalid(_))); } + #[tokio::test] + async fn docs_trigger_create_succeeds() { + let app_id = AppId::new(); + let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id); + let (status, Json(trigger)) = create_docs_trigger( + State(state), + Extension(member_principal()), + Path(app_id), + Json(CreateDocsTriggerRequest { + script_id: ScriptId::new(), + collection_glob: "users".into(), + ops: vec![DocsEventOp::Create, DocsEventOp::Update], + dispatch_mode: TriggerDispatchMode::Async, + retry_max_attempts: None, + retry_backoff: None, + retry_base_ms: None, + }), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::CREATED); + assert!(matches!( + trigger.kind, + crate::trigger_repo::TriggerKind::Docs + )); + match trigger.details { + TriggerDetails::Docs { + collection_glob, + ops, + } => { + assert_eq!(collection_glob, "users"); + assert_eq!(ops, vec![DocsEventOp::Create, DocsEventOp::Update]); + } + other => panic!("expected Docs details, got {other:?}"), + } + } + + #[tokio::test] + async fn docs_trigger_empty_glob_rejected() { + let app_id = AppId::new(); + let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id); + let res = create_docs_trigger( + State(state), + Extension(member_principal()), + Path(app_id), + Json(CreateDocsTriggerRequest { + script_id: ScriptId::new(), + collection_glob: " ".into(), + ops: vec![], + dispatch_mode: TriggerDispatchMode::Async, + retry_max_attempts: None, + retry_backoff: None, + retry_base_ms: None, + }), + ) + .await; + let err = res.expect_err("empty docs glob should reject"); + assert!(matches!(err, TriggersApiError::Invalid(_))); + } + + #[tokio::test] + async fn docs_trigger_member_without_role_is_forbidden() { + let app_id = AppId::new(); + let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id); + let res = create_docs_trigger( + State(state), + Extension(member_principal()), + Path(app_id), + Json(CreateDocsTriggerRequest { + script_id: ScriptId::new(), + collection_glob: "users".into(), + ops: vec![], + dispatch_mode: TriggerDispatchMode::Async, + retry_max_attempts: None, + retry_backoff: None, + retry_base_ms: None, + }), + ) + .await; + let err = res.expect_err("member without role should be forbidden"); + assert!(matches!(err, TriggersApiError::Forbidden)); + } + #[tokio::test] async fn delete_rejects_cross_app_trigger_id() { let app_a = AppId::new();