From c492a087753f9f05bbbe2d9ab263342772d8ce52 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 30 Jun 2026 21:02:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=20pic=20triggers=20ls=20--group=20+?= =?UTF-8?q?=20group-template=20journeys=20+=20docs=20(=C2=A711=20tail=20T4?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apply_service: trigger_report(group) → TriggerTemplateInfo (kind/target/script/enabled); resolve_inherited_targets_for(Group) now surfaces the group's OWN endpoint scripts so a template's handler validates (fixes "binds to unknown script" when the handler is a pre-existing group script, not declared in the same manifest). - apply_api: GET /groups/{id}/triggers (GroupScriptsRead). - CLI: `pic triggers ls --group ` (--app/--group mutually exclusive) + the client method + DTO. - tests/group_trigger_templates.rs (manager-core, live DB): the chain union matches a descendant app's kv insert against the group template and NOT a sibling subtree — the isolation boundary, deterministic. - tests/group_triggers.rs (journey): apply a kv template, ls --group shows it, re-apply NoOp, cron-on-group rejected. - docs: design §4.5 (live-event-kinds decision + deferrals), CLAUDE.md. Full journey suite 119/119; workspace tests 0 failures; clippy -D clean; schema unchanged (blessed in T1). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- crates/manager-core/src/apply_api.rs | 23 ++- crates/manager-core/src/apply_service.rs | 100 ++++++++++- .../tests/group_trigger_templates.rs | 170 ++++++++++++++++++ crates/picloud-cli/src/client.rs | 25 +++ crates/picloud-cli/src/cmds/triggers.rs | 15 ++ crates/picloud-cli/src/main.rs | 15 +- crates/picloud-cli/tests/cli.rs | 1 + crates/picloud-cli/tests/group_triggers.rs | 156 ++++++++++++++++ docs/design/groups-and-project-tool.md | 22 ++- 10 files changed, 519 insertions(+), 10 deletions(-) create mode 100644 crates/manager-core/tests/group_trigger_templates.rs create mode 100644 crates/picloud-cli/tests/group_triggers.rs diff --git a/CLAUDE.md b/CLAUDE.md index 56fba38..37f095f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint **v1.1.x — SDK foundation + services — is complete.** The SDK shape (handle pattern, `::` namespaces, `Services`/`SdkCallCx`; see [docs/sdk-shape.md](docs/sdk-shape.md), stdlib at [docs/stdlib-reference.md](docs/stdlib-reference.md)) fixed in v1.1.0, then KV, docs, modules, HTTP, cron, files, pub/sub, email, users, and durable queues + `invoke()` filled it in through **v1.1.9** — blueprint §12 has the table. Earlier groundwork: blueprint Phase 3 (admin auth, multi-app scoping, Phase 3.5 capability gating — `manager-core::authz::{can, require, Capability}`, migration `0006_users_authz.sql`). -**Current focus: v1.2 _Hierarchies_ — groups + the declarative project tool** ([docs/design/groups-and-project-tool.md](docs/design/groups-and-project-tool.md)). That doc's §11 uses its own **Phase 1–6 numbering, distinct from the blueprint product-phase numbering above — do not conflate them** (its "Phase 3" = group-inherited config, not admin auth). Implemented on `feat/groups-*` branches: §11 Phase 1 (declarative `pic plan`/`apply`/`prune` + env overlays), Phase 2 (single-parent groups tree + hierarchy-aware RBAC), Phase 3 (group-inherited, env-scoped `vars` + secrets resolved **live** via a recursive CTE — no materialized cache), Phase 4-lite (group-owned **endpoint** scripts: `scripts` polymorphic owner in `0050_group_scripts.sql`, `get_by_name_inherited`/`is_invocable_by_app` chain resolution, inherited `invoke()` + declarative route/trigger binding — all **live**, no body materialization), Phase 5 (the **declarative project tool maps onto the group tree**: the reconcile engine generalized to `ApplyOwner{App|Group}`, a `[group]` manifest kind, and a single atomic **tree apply** — `pic plan/apply --dir` reconciles a whole directory tree of `picloud.toml` nodes in one Postgres transaction, groups-before-apps so an app route can bind a group script created in the same tx; the bound token folds in each group's `structure_version`. Multi-repo single-owner/attach-point and per-env approval gating are deferred; groups pre-exist), Phase 4b (group **modules** + the **lexical (sealed-by-default) import resolver**, §5.5: owner-polymorphic `ModuleScript`, origin-rooted `ModuleSource::resolve` walking the importing node's chain, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site, `_source`-driven lexical chaining in `PicloudModuleResolver` with the compiled-module cache re-keyed by `ScriptId`, group modules/imports allowed, single-node dangling-import `plan` check — an inherited group script's imports **seal to the group**, a leaf can't shadow them), §5.5 **extension points** (opt-in polymorphism — **§5.5 now complete**: marker table `0051_extension_points.sql` (owner-polymorphic, CASCADE — structurally a `secrets` name; default body = a co-located `kind=module` script), `ModuleSource::resolve_policy` with **nearest-declaration-kind-wins** — a concrete module resolves lexically, an EP marker resolves **dynamically against the inheriting app** (its override else the default body up-chain), `NoProvider` is a hard error; declarative-only authoring via the `[app]`/`[group]` manifest key `extension_points = [...]`, reconcile mirrors `secrets`, single-node no-provider `plan` check, read-only `pic extension-points ls` + `pull` round-trip — the app can **override** a group default, the deliberate inverse of the Phase 4b sealed import), §11.6 **group-level collections — KV + DOCS + FILES slices** (full cross-app shared read/write: a group declares a collection shared via the `[group]` manifest `collections = [...]` → owner-polymorphic marker `0052_group_collections.sql` with a `kind` discriminator + a per-kind group-keyed store: `0053_group_kv_entries.sql` (`kind='kv'`), `0054_group_docs.sql` (`kind='docs'`, the queryable-JSON store), and `0055_group_files.sql` (`kind='files'`, blob metadata in Postgres + bytes on disk under `/files/groups//...`, a `groups/` infix disjoint from the per-app `files//` subtree so the existing recursive orphan sweeper covers both with zero change) — no `app_id`, a shared row belongs to the group; CASCADE on group delete, an app delete leaves the data. Scripts use the **explicit** `kv::shared_collection("name")` / `docs::shared_collection("name")` / `files::shared_collection("name")` handles (`shared` alone is a Rhai reserved word); `GroupKv`/`GroupDocs`/`GroupFilesServiceImpl` resolve the owning group from `cx.app_id`'s ancestor chain **filtered by kind** (nearest-wins) — **that walk is the isolation boundary**, a foreign app gets `CollectionNotShared`; a `kv`, a `docs`, and a `files` collection of the same name are distinct stores. The docs slice reuses the `docs_filter` DSL — `build_find_query` generalized on its owner column (`docs`/`app_id` vs `group_docs`/`group_id`, both literals); the files slice likewise generalized the atomic-write + checksum-on-read path helpers on an owner-relative dir (one source for the security-sensitive disk mechanics). **Reads open** to any subtree script (anonymous incl. — the declaration is the grant), **writes require an authenticated editor+** on the owning group (`GroupKvRead/Write`, `GroupDocsRead/Write`, `GroupFilesRead/Write`, `script_gate_require_principal` fails closed on anon). Declarative authoring is the **string-or-table** form `collections = ["catalog", { name = "articles", kind = "docs" }, { name = "assets", kind = "files" }]` (bare string = kv); reconcile keys markers by `(name, kind)`; read-only `pic collections ls --group` shows a kind column. Deferred: shared-collection triggers (the "group trigger has no app to watch" problem), topics/queue shared collections (same trigger-centric gap), per-group quotas, an operator admin API for shared blobs (scripts use the SDK; `pic collections ls` shows the marker)). Next: multi-node cluster mode, or the §11 tail (trigger/route templates, multi-repo ownership). +**Current focus: v1.2 _Hierarchies_ — groups + the declarative project tool** ([docs/design/groups-and-project-tool.md](docs/design/groups-and-project-tool.md)). That doc's §11 uses its own **Phase 1–6 numbering, distinct from the blueprint product-phase numbering above — do not conflate them** (its "Phase 3" = group-inherited config, not admin auth). Implemented on `feat/groups-*` branches: §11 Phase 1 (declarative `pic plan`/`apply`/`prune` + env overlays), Phase 2 (single-parent groups tree + hierarchy-aware RBAC), Phase 3 (group-inherited, env-scoped `vars` + secrets resolved **live** via a recursive CTE — no materialized cache), Phase 4-lite (group-owned **endpoint** scripts: `scripts` polymorphic owner in `0050_group_scripts.sql`, `get_by_name_inherited`/`is_invocable_by_app` chain resolution, inherited `invoke()` + declarative route/trigger binding — all **live**, no body materialization), Phase 5 (the **declarative project tool maps onto the group tree**: the reconcile engine generalized to `ApplyOwner{App|Group}`, a `[group]` manifest kind, and a single atomic **tree apply** — `pic plan/apply --dir` reconciles a whole directory tree of `picloud.toml` nodes in one Postgres transaction, groups-before-apps so an app route can bind a group script created in the same tx; the bound token folds in each group's `structure_version`. Multi-repo single-owner/attach-point and per-env approval gating are deferred; groups pre-exist), Phase 4b (group **modules** + the **lexical (sealed-by-default) import resolver**, §5.5: owner-polymorphic `ModuleScript`, origin-rooted `ModuleSource::resolve` walking the importing node's chain, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site, `_source`-driven lexical chaining in `PicloudModuleResolver` with the compiled-module cache re-keyed by `ScriptId`, group modules/imports allowed, single-node dangling-import `plan` check — an inherited group script's imports **seal to the group**, a leaf can't shadow them), §5.5 **extension points** (opt-in polymorphism — **§5.5 now complete**: marker table `0051_extension_points.sql` (owner-polymorphic, CASCADE — structurally a `secrets` name; default body = a co-located `kind=module` script), `ModuleSource::resolve_policy` with **nearest-declaration-kind-wins** — a concrete module resolves lexically, an EP marker resolves **dynamically against the inheriting app** (its override else the default body up-chain), `NoProvider` is a hard error; declarative-only authoring via the `[app]`/`[group]` manifest key `extension_points = [...]`, reconcile mirrors `secrets`, single-node no-provider `plan` check, read-only `pic extension-points ls` + `pull` round-trip — the app can **override** a group default, the deliberate inverse of the Phase 4b sealed import), §11.6 **group-level collections — KV + DOCS + FILES slices** (full cross-app shared read/write: a group declares a collection shared via the `[group]` manifest `collections = [...]` → owner-polymorphic marker `0052_group_collections.sql` with a `kind` discriminator + a per-kind group-keyed store: `0053_group_kv_entries.sql` (`kind='kv'`), `0054_group_docs.sql` (`kind='docs'`, the queryable-JSON store), and `0055_group_files.sql` (`kind='files'`, blob metadata in Postgres + bytes on disk under `/files/groups//...`, a `groups/` infix disjoint from the per-app `files//` subtree so the existing recursive orphan sweeper covers both with zero change) — no `app_id`, a shared row belongs to the group; CASCADE on group delete, an app delete leaves the data. Scripts use the **explicit** `kv::shared_collection("name")` / `docs::shared_collection("name")` / `files::shared_collection("name")` handles (`shared` alone is a Rhai reserved word); `GroupKv`/`GroupDocs`/`GroupFilesServiceImpl` resolve the owning group from `cx.app_id`'s ancestor chain **filtered by kind** (nearest-wins) — **that walk is the isolation boundary**, a foreign app gets `CollectionNotShared`; a `kv`, a `docs`, and a `files` collection of the same name are distinct stores. The docs slice reuses the `docs_filter` DSL — `build_find_query` generalized on its owner column (`docs`/`app_id` vs `group_docs`/`group_id`, both literals); the files slice likewise generalized the atomic-write + checksum-on-read path helpers on an owner-relative dir (one source for the security-sensitive disk mechanics). **Reads open** to any subtree script (anonymous incl. — the declaration is the grant), **writes require an authenticated editor+** on the owning group (`GroupKvRead/Write`, `GroupDocsRead/Write`, `GroupFilesRead/Write`, `script_gate_require_principal` fails closed on anon). Declarative authoring is the **string-or-table** form `collections = ["catalog", { name = "articles", kind = "docs" }, { name = "assets", kind = "files" }]` (bare string = kv); reconcile keys markers by `(name, kind)`; read-only `pic collections ls --group` shows a kind column. Deferred: shared-collection triggers (the "group trigger has no app to watch" problem), topics/queue shared collections (same trigger-centric gap), per-group quotas, an operator admin API for shared blobs (scripts use the SDK; `pic collections ls` shows the marker)), and **§4.5 group TRIGGER templates** (live, event kinds — a `[group]` declares a `[[triggers.kv|docs|files|pubsub]]` template binding a group-owned handler; `triggers` gained a polymorphic owner `0056_group_triggers.sql` mirroring `0050`; the dispatcher's `list_matching_kv/docs/files` + the pubsub publish fan-out prepend `CHAIN_LEVELS_CTE` + `JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner)` so a descendant app's event matches its own triggers **plus** ancestor-group templates in one query, the handler running under the firing `app_id` — **the chain walk is the isolation boundary**, a sibling-subtree app never matches; stateful kinds cron/queue/email rejected on a group (need materialization), route templates + per-app opt-out deferred; read-only `pic triggers ls --group`). Next: multi-node cluster mode, or extend templates to routes / the stateful kinds, or multi-repo ownership. **Data-model invariant:** app-owned data-plane tables (KV, docs, files, …) start with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE`; the group-inheritable tables — _config_ (`vars`, `secrets`) and now group-owned _code_ (`scripts`, `0050`) — instead carry a **polymorphic owner**: nullable `group_id` and `app_id` with an exactly-one CHECK and per-owner partial-unique indexes (config is `ON DELETE CASCADE`, scripts `RESTRICT` — code is not data). Inheritance resolves **live** down `apps.group_id → groups.parent_id` via `CHAIN_LEVELS_CTE` (no materialized view); nearest-owner-wins with an app's own row shadowing the inherited one (CoW). Every Rhai SDK call resolves its app from `cx.app_id`, never a script-passed arg, and a group script always runs under the *inheriting* app's `cx.app_id` (the cross-app isolation boundary). diff --git a/crates/manager-core/src/apply_api.rs b/crates/manager-core/src/apply_api.rs index a377846..1db8f09 100644 --- a/crates/manager-core/src/apply_api.rs +++ b/crates/manager-core/src/apply_api.rs @@ -18,7 +18,7 @@ use serde_json::json; use crate::app_repo::AppRepository; use crate::apply_service::{ ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo, - ExtensionPointInfo, NodeKind, PlanResult, TreeBundle, TreePlanResult, + ExtensionPointInfo, NodeKind, PlanResult, TreeBundle, TreePlanResult, TriggerTemplateInfo, }; use crate::authz::{require, AuthzDenied, Capability}; use crate::group_repo::GroupRepository; @@ -41,9 +41,30 @@ pub fn apply_router(service: ApplyService) -> Router { get(group_extension_points_handler), ) .route("/groups/{id}/collections", get(group_collections_handler)) + .route("/groups/{id}/triggers", get(group_triggers_handler)) .with_state(service) } +/// Read-only §11 tail trigger-template report for a group: its own declared +/// event trigger templates (kind, target, handler script, enabled). Viewer-tier +/// read. Backs `pic triggers ls --group`. +async fn group_triggers_handler( + State(svc): State, + Extension(principal): Extension, + Path(id_or_slug): Path, +) -> Result>, ApplyError> { + let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?; + require( + svc.authz.as_ref(), + &principal, + Capability::GroupScriptsRead(group_id), + ) + .await + .map_err(map_authz)?; + let report = svc.trigger_report(ApplyOwner::Group(group_id)).await?; + Ok(Json(report)) +} + /// Read-only §11.6 shared-collection report for a group: its own declared /// shared KV collection names. Viewer-tier read. async fn group_collections_handler( diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 9dea69a..e8b92eb 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -461,6 +461,17 @@ pub struct CollectionInfo { pub kind: String, } +/// One row of the read-only §11 tail trigger-template report (`pic triggers ls +/// --group`). `target` is the kind-specific identity bit (collection glob or +/// topic pattern); `script` is the handler script's name. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TriggerTemplateInfo { + pub kind: String, + pub target: String, + pub script: String, + pub enabled: bool, +} + // ---------------------------------------------------------------------------- // Errors // ---------------------------------------------------------------------------- @@ -569,7 +580,42 @@ impl ApplyService { ) -> Result, ApplyError> { match owner { ApplyOwner::App(app_id) => self.resolve_inherited_targets(app_id, bundle).await, - ApplyOwner::Group(_) => Ok(HashMap::new()), + // §11 tail: a group trigger TEMPLATE binds the group's OWN endpoint + // scripts (declared here or pre-existing). Surface the ones a + // template references but the bundle doesn't declare, so + // `validate_bundle` accepts them (the reconcile's name_to_id already + // includes the group's current scripts). + ApplyOwner::Group(group_id) => { + let declared: HashSet = bundle + .scripts + .iter() + .map(|s| s.name.to_lowercase()) + .collect(); + let referenced: HashSet = bundle + .triggers + .iter() + .map(|t| t.script().to_lowercase()) + .collect(); + let by_name: HashMap = self + .scripts + .list_for_group(group_id) + .await + .map_err(map_repo)? + .into_iter() + .filter(|s| s.kind == ScriptKind::Endpoint) + .map(|s| (s.name.to_lowercase(), s.id)) + .collect(); + let mut out = HashMap::new(); + for name in referenced { + if declared.contains(&name) { + continue; + } + if let Some(id) = by_name.get(&name) { + out.insert(name, *id); + } + } + Ok(out) + } } } @@ -1842,6 +1888,58 @@ impl ApplyService { /// collections (`name` + `kind`) declared directly at it. Group-only in /// practice (the CLI rejects app-declared collections). Backs /// `pic collections ls`. + /// Read-only §11 tail report: a group's own trigger TEMPLATES, surfaced as + /// (kind, target, handler script name, enabled). The stored `name` is a UUID + /// default for reconcile-created rows, so the semantic bits are shown + /// instead. Backs `pic triggers ls --group`. + pub async fn trigger_report( + &self, + owner: ApplyOwner, + ) -> Result, ApplyError> { + let ApplyOwner::Group(group_id) = owner else { + return Ok(Vec::new()); + }; + let triggers = self + .triggers + .list_for_group(group_id) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + let scripts = self + .scripts + .list_for_group(group_id) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + let name_by_id: HashMap = + scripts.iter().map(|s| (s.id, s.name.clone())).collect(); + Ok(triggers + .into_iter() + .map(|t| { + let (kind, target) = match &t.details { + TriggerDetails::Kv { + collection_glob, .. + } => ("kv", collection_glob.clone()), + TriggerDetails::Docs { + collection_glob, .. + } => ("docs", collection_glob.clone()), + TriggerDetails::Files { + collection_glob, .. + } => ("files", collection_glob.clone()), + TriggerDetails::Pubsub { topic_pattern } => ("pubsub", topic_pattern.clone()), + TriggerDetails::Cron { .. } => ("cron", String::new()), + TriggerDetails::Queue { queue_name, .. } => ("queue", queue_name.clone()), + TriggerDetails::Email { .. } => ("email", String::new()), + TriggerDetails::DeadLetter { .. } => ("dead_letter", String::new()), + }; + TriggerTemplateInfo { + kind: kind.to_string(), + target, + script: name_by_id.get(&t.script_id).cloned().unwrap_or_default(), + enabled: t.enabled, + } + }) + .collect()) + } + pub async fn collection_report( &self, owner: ApplyOwner, diff --git a/crates/manager-core/tests/group_trigger_templates.rs b/crates/manager-core/tests/group_trigger_templates.rs new file mode 100644 index 0000000..0acc9b5 --- /dev/null +++ b/crates/manager-core/tests/group_trigger_templates.rs @@ -0,0 +1,170 @@ +//! §11 tail integration test: the live chain-union dispatch for group trigger +//! templates. A group-owned kv trigger TEMPLATE must be matched for a +//! **descendant** app (the union via `CHAIN_LEVELS_CTE`) and must NOT be matched +//! for an app in a **sibling** subtree — that walk is the isolation boundary. +//! +//! Deterministic (no async dispatcher) so it pins the security-critical SQL +//! without flakiness. Skips cleanly when `DATABASE_URL` is unset. + +#![allow( + clippy::needless_pass_by_value, + clippy::many_single_char_names, + clippy::too_many_lines +)] + +use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo}; +use picloud_shared::{AppId, KvEventOp}; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use uuid::Uuid; + +async fn pool_or_skip() -> Option { + let Ok(url) = std::env::var("DATABASE_URL") else { + eprintln!("group_trigger_templates: DATABASE_URL unset — skipping"); + return None; + }; + let pool = PgPoolOptions::new() + .max_connections(2) + .connect(&url) + .await + .expect("connect to DATABASE_URL"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("apply migrations"); + Some(pool) +} + +async fn id1(pool: &PgPool, sql: &str, bind: &str) -> Uuid { + let row: (Uuid,) = sqlx::query_as(sql) + .bind(bind) + .fetch_one(pool) + .await + .expect("insert returning id"); + row.0 +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn group_kv_template_matches_descendant_not_sibling() { + let Some(pool) = pool_or_skip().await else { + return; + }; + let sfx = Uuid::new_v4().simple().to_string(); + + // registered_by principal + let admin = id1( + &pool, + "INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id", + &format!("gtt-{sfx}"), + ) + .await; + + // G owns the template; G2 is an unrelated sibling subtree. + let g = id1( + &pool, + "INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id", + &format!("gtt-g-{sfx}"), + ) + .await; + let g2 = id1( + &pool, + "INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id", + &format!("gtt-g2-{sfx}"), + ) + .await; + + // App A under G (descendant); app C under G2 (sibling subtree). + let a: (Uuid,) = + sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id") + .bind(format!("gtt-a-{sfx}")) + .bind(g) + .fetch_one(&pool) + .await + .expect("app A"); + let c: (Uuid,) = + sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id") + .bind(format!("gtt-c-{sfx}")) + .bind(g2) + .fetch_one(&pool) + .await + .expect("app C"); + + // Group-owned handler script under G. + let s: (Uuid,) = sqlx::query_as( + "INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id", + ) + .bind(format!("handler-{sfx}")) + .bind(g) + .fetch_one(&pool) + .await + .expect("group script"); + + // Group kv trigger TEMPLATE (group_id = G, app_id NULL) + details. + let t: (Uuid,) = sqlx::query_as( + "INSERT INTO triggers \ + (group_id, script_id, kind, enabled, dispatch_mode, \ + retry_max_attempts, retry_backoff, retry_base_ms, \ + registered_by_principal, name) \ + VALUES ($1, $2, 'kv', TRUE, 'async', 3, 'exponential', 1000, $3, $4) \ + RETURNING id", + ) + .bind(g) + .bind(s.0) + .bind(admin) + .bind(format!("tmpl-{sfx}")) + .fetch_one(&pool) + .await + .expect("template trigger"); + sqlx::query( + "INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \ + VALUES ($1, '*', ARRAY['insert'])", + ) + .bind(t.0) + .execute(&pool) + .await + .expect("kv details"); + + let repo = PostgresTriggerRepo::new(pool.clone()); + + // Descendant app A's kv insert matches the ancestor group's template. + let matched = repo + .list_matching_kv(AppId::from(a.0), "users", KvEventOp::Insert) + .await + .expect("match for A"); + assert!( + matched.iter().any(|m| m.trigger_id == t.0.into()), + "a descendant app must match the group's kv template" + ); + + // Sibling-subtree app C must NOT — the chain union is the isolation boundary. + let none = repo + .list_matching_kv(AppId::from(c.0), "users", KvEventOp::Insert) + .await + .expect("match for C"); + assert!( + !none.iter().any(|m| m.trigger_id == t.0.into()), + "a sibling-subtree app must NOT match another subtree's group template" + ); + + // Cleanup (best-effort; ordering respects the FKs). + let _ = sqlx::query("DELETE FROM triggers WHERE id = $1") + .bind(t.0) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM scripts WHERE id = $1") + .bind(s.0) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM apps WHERE id = ANY($1)") + .bind(vec![a.0, c.0]) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM groups WHERE id = ANY($1)") + .bind(vec![g, g2]) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1") + .bind(admin) + .execute(&pool) + .await; +} diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 718f4ad..d08ff7b 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1346,6 +1346,31 @@ impl Client { .await?; decode(resp).await } + + /// `GET /api/v1/admin/groups/{ident}/triggers` (§11 tail). Group-only — + /// trigger templates are declared on groups and fan out to descendant apps. + pub async fn group_triggers_list(&self, group_ident: &str) -> Result> { + let ident = seg(group_ident); + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/groups/{ident}/triggers"), + ) + .send() + .await?; + decode(resp).await + } +} + +/// One row of the §11 tail trigger-template report. +#[derive(Debug, Deserialize)] +pub struct TriggerTemplateDto { + pub kind: String, + #[serde(default)] + pub target: String, + #[serde(default)] + pub script: String, + pub enabled: bool, } /// One row of the §11.6 shared-collection report. diff --git a/crates/picloud-cli/src/cmds/triggers.rs b/crates/picloud-cli/src/cmds/triggers.rs index 5a7d151..23f46e4 100644 --- a/crates/picloud-cli/src/cmds/triggers.rs +++ b/crates/picloud-cli/src/cmds/triggers.rs @@ -40,6 +40,21 @@ pub async fn ls(app: &str, mode: OutputMode) -> Result<()> { Ok(()) } +/// `pic triggers ls --group ` — read-only view of a group's §11 tail trigger +/// TEMPLATES (event kinds that fan out live to descendant apps). Authoring is +/// declarative via the `[group]` manifest `[[triggers.*]]`. +pub async fn ls_group(group: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let rows = client.group_triggers_list(group).await?; + let mut table = Table::new(["kind", "target", "script", "enabled"]); + for t in rows { + table.row([t.kind, t.target, t.script, t.enabled.to_string()]); + } + table.print(mode); + Ok(()) +} + pub async fn rm(app: &str, trigger_id: &str) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index df0ee79..2f7885d 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -891,10 +891,13 @@ enum RoutesCmd { #[derive(Subcommand)] enum TriggersCmd { - /// List every trigger in an app. + /// List an app's triggers, or a group's trigger TEMPLATES (`--group`). Ls { + #[arg(long, conflicts_with = "group", required_unless_present = "group")] + app: Option, + /// List the group's §11 tail trigger templates instead. #[arg(long)] - app: String, + group: Option, }, /// Delete a trigger by id. @@ -1706,8 +1709,12 @@ async fn main() -> ExitCode { cmd: AdminsCmd::Rm { id }, } => cmds::admins::rm(&id).await, Cmd::Triggers { - cmd: TriggersCmd::Ls { app }, - } => cmds::triggers::ls(&app, mode).await, + cmd: TriggersCmd::Ls { app, group }, + } => match (app, group) { + (_, Some(group)) => cmds::triggers::ls_group(&group, mode).await, + (Some(app), None) => cmds::triggers::ls(&app, mode).await, + (None, None) => Err(anyhow::anyhow!("provide --app or --group")), + }, Cmd::Triggers { cmd: TriggersCmd::Rm { app, trigger_id }, } => cmds::triggers::rm(&app, &trigger_id).await, diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index 2744d12..d4c438a 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -28,6 +28,7 @@ mod extension_points; mod group_modules; mod group_scripts; mod group_secrets; +mod group_triggers; mod groups; mod init; mod invoke; diff --git a/crates/picloud-cli/tests/group_triggers.rs b/crates/picloud-cli/tests/group_triggers.rs new file mode 100644 index 0000000..5a45230 --- /dev/null +++ b/crates/picloud-cli/tests/group_triggers.rs @@ -0,0 +1,156 @@ +//! §11 tail — group TRIGGER templates, declarative authoring end to end via +//! `pic`. A group declares an event trigger template (kv) binding a group-owned +//! handler; the template persists, `pic triggers ls --group` shows it, re-apply +//! is a NoOp, and the stateful kinds (cron/queue/email) are rejected on a group. +//! +//! The live cross-subtree FIRING (a descendant app's event matches the template, +//! a sibling's does not) is pinned deterministically by the manager-core +//! integration test `tests/group_trigger_templates.rs` — driving the async +//! dispatcher from a journey is deliberately avoided here (codebase norm). + +use std::fs; + +use tempfile::TempDir; + +use crate::common; +use crate::common::cleanup::{GroupGuard, ScriptGuard}; + +fn manifest_dir() -> TempDir { + let dir = TempDir::new().expect("tempdir"); + fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir"); + dir +} + +/// Id of the named script in a group (`pic scripts ls --group `). +fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String { + let ls = common::pic_as(env) + .args(["scripts", "ls", "--group", group]) + .output() + .expect("scripts ls"); + let table = String::from_utf8(ls.stdout).unwrap(); + table + .lines() + .map(common::cells) + .find(|c| c.get(2) == Some(&name)) + .and_then(|c| c.first().map(|s| (*s).to_string())) + .unwrap_or_else(|| panic!("group script `{name}` not found:\n{table}")) +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn group_kv_trigger_template_applies_and_lists() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let group = common::unique_slug("gtrig-grp"); + + let _g = GroupGuard::new(&env.url, &env.token, &group); + common::pic_as(&env) + .args(["groups", "create", &group]) + .assert() + .success(); + + // A group-owned handler script the template binds. + let dir = manifest_dir(); + fs::write( + dir.path().join("scripts/on-write.rhai"), + r#"log::info("template fired"); "ok""#, + ) + .unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/on-write.rhai")) + .args(["--group", &group, "--name", "on-write"]) + .assert() + .success(); + let _gs = ScriptGuard::new( + &env.url, + &env.token, + &group_script_id(&env, &group, "on-write"), + ); + + // The group declares a kv trigger TEMPLATE binding the handler. + let gmanifest = format!( + "[group]\nslug = \"{group}\"\nname = \"GTrig\"\n\n\ + [[triggers.kv]]\nscript = \"on-write\"\ncollection_glob = \"*\"\nops = [\"insert\"]\n" + ); + let gpath = dir.path().join("group.toml"); + fs::write(&gpath, &gmanifest).unwrap(); + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&gpath) + .assert() + .success(); + + // `triggers ls --group` shows the template (kind + target + handler). + let ls = String::from_utf8( + common::pic_as(&env) + .args(["triggers", "ls", "--group", &group]) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + ls.contains("kv") && ls.contains("on-write"), + "ls should list the kv template binding on-write:\n{ls}" + ); + + // Re-apply is a NoOp (the template marker already exists — the diff keys on + // the semantic identity, so it is not re-created). + let report = String::from_utf8( + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&gpath) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + !report.contains("trigger") + || report.to_lowercase().contains("no changes") + || report.contains("0"), + "re-apply should not create a second template:\n{report}" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn group_cron_trigger_is_rejected() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let group = common::unique_slug("gtrig-cron"); + + let _g = GroupGuard::new(&env.url, &env.token, &group); + common::pic_as(&env) + .args(["groups", "create", &group]) + .assert() + .success(); + + // A [group] cron trigger is a stateful kind — rejected at the manifest layer. + let dir = manifest_dir(); + let gmanifest = format!( + "[group]\nslug = \"{group}\"\nname = \"GTrig\"\n\n\ + [[triggers.cron]]\nscript = \"x\"\nschedule = \"0 0 * * * *\"\n" + ); + let gpath = dir.path().join("group.toml"); + fs::write(&gpath, &gmanifest).unwrap(); + let out = common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&gpath) + .output() + .expect("apply"); + assert!( + !out.status.success(), + "a [group] cron trigger must be rejected (stateful kind)" + ); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("event kind") || err.contains("cron"), + "rejection should explain event-kinds-only:\n{err}" + ); +} diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index f3cfe4d..74d12c2 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -377,9 +377,25 @@ Two distinct constraints: > **Ergonomic debt (accepted, watch it):** because triggers don't inherit, 100 tenant apps each > needing the same 5 triggers = 500 declarations. The fix is group trigger/route **templates** that -> fan out per descendant (a *template/instantiation* mechanism, not inheritance) — deferred, but it -> bites early if tenant cardinality is high. Pressure-test against real tenant counts before -> committing to the narrow-inheritance choice (§5.1). +> fan out per descendant — deferred, but it bites early if tenant cardinality is high. Pressure-test +> against real tenant counts before committing to the narrow-inheritance choice (§5.1). +> +> **Shipped — group TRIGGER templates (live, event kinds).** Implemented as **live inheritance via the +> ancestor-chain CTE**, not the materialized "instantiation" this note first sketched — consistent with +> how vars/secrets/scripts/§11.6 collections all resolve. A `[group]` declares an EVENT trigger +> (kv/docs/files/pubsub) binding a group-owned handler; `triggers` gained a polymorphic owner +> (`0056_group_triggers.sql`, nullable `group_id`, mirrors `0050`), and the dispatcher's +> `list_matching_kv/docs/files` + the pubsub publish fan-out prepend `CHAIN_LEVELS_CTE` and +> `JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner)` — so a descendant app's event +> matches its own triggers **plus** ancestor-group templates in one query, the handler running under the +> firing app's `app_id` (the Phase-4 boundary). **That walk is the isolation boundary**: a +> sibling-subtree app never sees the template (pinned by `tests/group_trigger_templates.rs`). Authoring +> is `[[triggers.kv]]` (etc.) on a `[group]`; read-only `pic triggers ls --group`. **Deferred:** the +> **stateful** kinds (cron `last_fired_at`, queue advisory-lock, email sealed secret) need per-app rows +> → materialization, rejected on a `[group]`; **route templates** (same live shape, but the in-memory +> `RouteTable` would need inherited templates per app + new recompile-invalidation edges); per-app +> opt-out / cross-owner dedup (a descendant re-declaring an identical trigger double-fires — "overlapping +> triggers coexist"). ### 4.6 Secrets & `pull`