feat(modules): live chain-union dispatch for group trigger templates (§11 tail T3)

The runtime heart: a descendant app's event now fires its ancestor
groups' trigger templates, resolved LIVE (no materialization).

- trigger_repo: list_matching_kv/docs/files prepend CHAIN_LEVELS_CTE and
  `JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner)`
  (bind $1 = app_id), so each returns the app's own triggers PLUS
  ancestor-group templates in one statement. NULL-comparison semantics
  keep app/group rows from cross-matching; each row matches at exactly one
  chain depth (no dup fan-out).
- pubsub_repo: same union on the publish fan-out query.
- The outbox row stamps the firing app_id + the (group-owned) script_id;
  the dispatcher's existing script_invocable() (app-owned OR invocable via
  chain membership) already runs a group handler under a descendant app —
  the Phase-4 inheriting-app boundary, unchanged.

Glob/ops/topic filtering in Rust is untouched. 404 manager-core unit
tests green; clippy -D clean. Cross-subtree firing is journey-proven in T4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-30 20:43:01 +02:00
parent 547004e32d
commit 72802de644
2 changed files with 31 additions and 16 deletions

View File

@@ -16,6 +16,8 @@ use picloud_shared::{topic_matches, AdminUserId, AppId, ExecutionId};
use sqlx::PgPool;
use uuid::Uuid;
use crate::config_resolver::CHAIN_LEVELS_CTE;
#[derive(Debug, thiserror::Error)]
pub enum PubsubRepoError {
#[error("database error: {0}")]
@@ -78,12 +80,18 @@ impl PubsubRepo for PostgresPubsubRepo {
// 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<PubsubTriggerRow> = sqlx::query_as(
"SELECT t.id, t.script_id, d.topic_pattern \
// §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.
let rows: Vec<PubsubTriggerRow> = 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 \
WHERE t.app_id = $1 AND t.kind = 'pubsub' AND t.enabled = TRUE",
)
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"
))
.bind(ctx.app_id.into_inner())
.fetch_all(&mut *tx)
.await?;