feat(v1.1.2-docs): triggers framework + dispatcher + emitter extended for docs

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-02 19:55:27 +02:00
parent 06678f4496
commit ef5930910b
5 changed files with 425 additions and 9 deletions

View File

@@ -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<DocsEventOp>,
#[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>,
}
#[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<TriggersState>,
Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>,
Json(input): Json<CreateDocsTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), 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<TriggersState>,
Extension(principal): Extension<Principal>,
@@ -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<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::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<Vec<KvTriggerMatch>, TriggerRepoError> {
Ok(vec![])
}
async fn list_matching_docs(
&self,
_app_id: AppId,
_collection: &str,
_op: DocsEventOp,
) -> Result<Vec<DocsTriggerMatch>, 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();