From 4d2eed4e8193d5fb1ae4df6eaf8d94f141f617d9 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 28 Jun 2026 17:38:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(hierarchies):=20trigger=20templates=20?= =?UTF-8?q?=E2=80=94=20per-app=20fan-out=20(M4b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes §4.5 templates (M4a shipped routes). A group declares a trigger once; the tree apply fans it out into one concrete per-app_id trigger on each descendant app, reusing the M4a expansion engine over the trigger insert path. - Migration 0054: `trigger_templates` table (group-owned; the whole BundleTrigger wire object stored as `spec` JSONB, placeholders unresolved) + `triggers.from_template` provenance column + index. - `template_repo.rs`: trigger-template CRUD + chain-load. - Manifest: `[group]` `[[trigger_templates]]` (flat: name, kind, script, + kind params); build_bundle emits them; rejected on an app node. - Apply: group nodes reconcile trigger-template rows; tree Phase B expands each chain trigger template into a concrete trigger per descendant app — placeholders resolved in every spec string leaf, then the typed trigger is rebuilt and inserted through the shared `insert_bundle_trigger_tx` (email secrets resolved + re-sealed per recipient app). Idempotent via from_template (one trigger per template per app; semantic-identity compare → no-op / delete+recreate / reap). Collision with a hand-declared trigger or between templates is a hard error. Each resolved trigger is re-validated with the per-kind shape check (cron/queue/etc.) so a {var:}-injected bad schedule/timeout can't slip in. - Authz: declaring → GroupScriptsWrite; receiving → AppManageTriggers, plus AppSecretsRead for email-trigger recipients (parity with hand-declared email). - Plan/token/report extended for trigger templates; blast radius covers both. - Tests: tests/templates.rs trigger fan-out (kv + {app_slug}, stable-ids, prune-reap); resolve_placeholders_in_json unit test; schema golden reblessed. Reviewed (no CRITICAL; isolation + per-app email-secret sealing verified sound). Closed a HIGH validation-parity gap (re-validate resolved triggers) and a MEDIUM authz gap (AppSecretsRead for email expansions). v1.2 Hierarchies template work (M4) complete. Co-Authored-By: Claude Opus 4.8 --- .../migrations/0054_trigger_templates.sql | 33 ++ crates/manager-core/src/apply_api.rs | 125 +++-- crates/manager-core/src/apply_service.rs | 529 ++++++++++++++++-- crates/manager-core/src/template_repo.rs | 118 ++++ crates/manager-core/src/trigger_repo.rs | 12 +- crates/manager-core/tests/expected_schema.txt | 19 + crates/picloud-cli/src/client.rs | 8 + crates/picloud-cli/src/cmds/apply.rs | 14 + crates/picloud-cli/src/cmds/init.rs | 1 + crates/picloud-cli/src/cmds/plan.rs | 13 +- crates/picloud-cli/src/cmds/pull.rs | 3 +- crates/picloud-cli/src/manifest.rs | 19 + crates/picloud-cli/tests/templates.rs | 113 ++++ docs/design/groups-and-project-tool.md | 44 +- 14 files changed, 953 insertions(+), 98 deletions(-) create mode 100644 crates/manager-core/migrations/0054_trigger_templates.sql diff --git a/crates/manager-core/migrations/0054_trigger_templates.sql b/crates/manager-core/migrations/0054_trigger_templates.sql new file mode 100644 index 0000000..dcc5963 --- /dev/null +++ b/crates/manager-core/migrations/0054_trigger_templates.sql @@ -0,0 +1,33 @@ +-- Phase 5 / M4b (v1.2 Hierarchies): TRIGGER templates (§4.5). +-- +-- The trigger half of templates (M4a shipped routes). A `[group]` manifest +-- declares `[[trigger_templates.]]`; the apply fans each one out into a +-- concrete per-`app_id` trigger on every descendant app, reusing the same +-- expansion engine, placeholder set (`{app_slug}`/`{env}`/`{var:NAME}`), and +-- `from_template` provenance as routes. +-- +-- A trigger's parameters vary by kind (collection_glob/ops, schedule/timezone, +-- topic_pattern, queue_name, inbound_secret_ref, …), so the template stores the +-- whole `BundleTrigger` wire object as `spec` JSONB (kind tag included). The +-- expansion resolves placeholders in the spec's string leaves, then rebuilds the +-- typed trigger and inserts it through the normal trigger path (email secrets +-- are resolved + re-sealed per app, exactly like a hand-declared email trigger). + +CREATE TABLE trigger_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + -- Identity/upsert key, unique per group (case-insensitive). + name TEXT NOT NULL, + -- The full `BundleTrigger` wire object (internally tagged by `kind`), with + -- placeholders left unresolved. Rebuilt + resolved per descendant at apply. + spec JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX trigger_templates_group_name_idx + ON trigger_templates (group_id, LOWER(name)); + +-- Provenance on expanded triggers (mirrors routes.from_template, 0053). NULL = +-- hand-declared; non-NULL = stamped from this template, managed by expansion. +ALTER TABLE triggers ADD COLUMN from_template UUID; +CREATE INDEX triggers_from_template_idx ON triggers (app_id, from_template); diff --git a/crates/manager-core/src/apply_api.rs b/crates/manager-core/src/apply_api.rs index 2d7610b..b7bace2 100644 --- a/crates/manager-core/src/apply_api.rs +++ b/crates/manager-core/src/apply_api.rs @@ -266,12 +266,13 @@ async fn authz_tree( Some(prune) => { require_app_node_writes(svc, principal, app_id, &node.bundle, prune) .await?; - // Template expansion (§4.5, M4a) writes routes INTO this - // app, so the actor needs AppWriteRoute when the app's - // chain carries route templates — even if the app itself - // declares no routes. Without this, expanding routes into + // Template expansion (§4.5) writes routes/triggers INTO + // this app, so the actor needs the matching write cap + // when the app's chain carries templates — even if the + // app itself declares none. Without this, expanding into // an app a principal can't write would slip the gate. - if app_receives_template_expansions(svc, app_id, bundle).await? { + let recv = app_receives_template_expansions(svc, app_id, bundle).await?; + if recv.routes { require( svc.authz.as_ref(), principal, @@ -280,6 +281,27 @@ async fn authz_tree( .await .map_err(map_authz)?; } + if recv.triggers { + require( + svc.authz.as_ref(), + principal, + Capability::AppManageTriggers(app_id), + ) + .await + .map_err(map_authz)?; + } + // Email-trigger expansion resolves + seals the recipient + // app's secret server-side — same `AppSecretsRead` gate a + // hand-declared email trigger requires. + if recv.email { + require( + svc.authz.as_ref(), + principal, + Capability::AppSecretsRead(app_id), + ) + .await + .map_err(map_authz)?; + } } None => { require(svc.authz.as_ref(), principal, Capability::AppRead(app_id)) @@ -486,12 +508,13 @@ async fn require_group_node_writes( bundle: &Bundle, prune: bool, ) -> Result<(), ApplyError> { - // Extension points and route templates gate on the script-write tier (see - // the app variant) — both are code-binding declarations, not viewer-tier. + // Extension points and route/trigger templates gate on the script-write + // tier (see the app variant) — all are code-binding declarations. if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() || !bundle.route_templates.is_empty() + || !bundle.trigger_templates.is_empty() { require( svc.authz.as_ref(), @@ -513,22 +536,36 @@ async fn require_group_node_writes( Ok(()) } -/// Will route-template expansion (§4.5, M4a) write routes into `app_id` during -/// this tree apply? True iff the app's ancestor chain carries a route template, -/// counting both committed templates and ones declared by a group node in THIS -/// bundle. Drives the `AppWriteRoute` gate for expansion recipients. +/// What template expansion (§4.5) will write into `app_id` during this tree +/// apply. Returns `Recipient { routes, triggers, email }` — whether the app's +/// ancestor chain carries a route / trigger / EMAIL-trigger template (committed, +/// or declared by a group node in THIS bundle). Drives the `AppWriteRoute` / +/// `AppManageTriggers` / `AppSecretsRead` gates for recipients (email expansion +/// resolves + seals the recipient app's secret server-side, like a hand-declared +/// email trigger). +#[derive(Default)] +struct Recipient { + routes: bool, + triggers: bool, + email: bool, +} + +fn spec_is_email(spec: &serde_json::Value) -> bool { + spec.get("kind").and_then(serde_json::Value::as_str) == Some("email") +} + async fn app_receives_template_expansions( svc: &ApplyService, app_id: AppId, bundle: &TreeBundle, -) -> Result { +) -> Result { let Some(app) = svc .apps .get_by_id(app_id) .await .map_err(|e| ApplyError::Backend(e.to_string()))? else { - return Ok(false); + return Ok(Recipient::default()); }; let ancestors = svc .groups @@ -537,33 +574,55 @@ async fn app_receives_template_expansions( .map_err(|e| ApplyError::Backend(e.to_string()))?; let ancestor_ids: std::collections::HashSet = ancestors.iter().map(|g| g.id).collect(); + let mut r = Recipient::default(); // Committed templates on any ancestor group. for gid in &ancestor_ids { - if !crate::template_repo::list_for_group(&svc.pool, *gid) - .await - .map_err(|e| ApplyError::Backend(e.to_string()))? - .is_empty() - { - return Ok(true); - } - } - // Templates newly declared by a group node in this bundle that is an - // ancestor of the app (existing groups only — a to-create group has no apps). - for node in &bundle.nodes { - if node.kind == NodeKind::Group && !node.bundle.route_templates.is_empty() { - if let Some(g) = svc - .groups - .get_by_slug(&node.slug) + if !r.routes + && !crate::template_repo::list_for_group(&svc.pool, *gid) .await .map_err(|e| ApplyError::Backend(e.to_string()))? - { - if ancestor_ids.contains(&g.id) { - return Ok(true); - } + .is_empty() + { + r.routes = true; + } + for tt in crate::template_repo::list_triggers_for_group(&svc.pool, *gid) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))? + { + r.triggers = true; + r.email = r.email || spec_is_email(&tt.spec); + } + } + // Templates newly declared by a group node in this bundle that is an ancestor + // of the app (existing groups only — a to-create group has no apps). + for node in &bundle.nodes { + if node.kind != NodeKind::Group { + continue; + } + let declares_routes = !node.bundle.route_templates.is_empty(); + let declares_triggers = !node.bundle.trigger_templates.is_empty(); + if !declares_routes && !declares_triggers { + continue; + } + if let Some(g) = svc + .groups + .get_by_slug(&node.slug) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))? + { + if ancestor_ids.contains(&g.id) { + r.routes = r.routes || declares_routes; + r.triggers = r.triggers || declares_triggers; + r.email = r.email + || node + .bundle + .trigger_templates + .iter() + .any(|t| spec_is_email(&t.spec)); } } } - Ok(false) + Ok(r) } /// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404. diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 82c20e4..27183ee 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -94,6 +94,11 @@ pub struct Bundle { /// (and rejected) on an app node — an app declares concrete `routes`. #[serde(default)] pub route_templates: Vec, + /// Trigger templates (§4.5, M4b): GROUP-only, the trigger analogue of + /// `route_templates`. Each fans out into a concrete per-`app_id` trigger on + /// every descendant app. Rejected on an app node. + #[serde(default)] + pub trigger_templates: Vec, } /// A route template: a route declared once on a group, expanded into one @@ -107,6 +112,19 @@ pub struct RouteTemplateSpec { pub route: BundleRoute, } +/// A trigger template: a trigger declared once on a group, expanded into one +/// concrete trigger per descendant app (§4.5, M4b). `spec` is the full +/// [`BundleTrigger`] wire object (kind tag + script + params) with placeholders +/// left unresolved; kept as raw JSON so one shape covers all seven kinds and the +/// expansion can substitute placeholders in its string leaves before rebuilding +/// the typed trigger. +#[derive(Debug, Clone, Deserialize)] +pub struct TriggerTemplateSpec { + pub name: String, + #[serde(flatten)] + pub spec: serde_json::Value, +} + /// Desired extension point — a module name opened for dynamic, per-tenant /// resolution, with an optional default body (a module declared at this node). #[derive(Debug, Clone, Deserialize)] @@ -365,6 +383,9 @@ pub struct Plan { /// (keyed by the resolved route, detail noting the source template). #[serde(default)] pub route_templates: Vec, + /// Trigger-template changes on a GROUP node (§4.5, M4b). + #[serde(default)] + pub trigger_templates: Vec, } impl Plan { @@ -379,6 +400,7 @@ impl Plan { .chain(&self.vars) .chain(&self.extension_points) .chain(&self.route_templates) + .chain(&self.trigger_templates) .all(|c| c.op == Op::NoOp) } } @@ -503,6 +525,8 @@ pub struct CurrentState { /// A GROUP owner's OWN route templates (§4.5, M4a), for the name-based diff. /// Empty for an app owner (apps don't own templates). pub route_templates: Vec, + /// A GROUP owner's OWN trigger templates (§4.5, M4b). + pub trigger_templates: Vec, } // ---------------------------------------------------------------------------- @@ -643,14 +667,15 @@ impl ApplyService { .into(), )); } - // Templates ARE allowed on a group — validate each (§4.5 M4a). + // Templates ARE allowed on a group — validate each (§4.5). validate_route_templates(&bundle.route_templates)?; + validate_trigger_templates(&bundle.trigger_templates)?; } ApplyOwner::App(_) => { - if !bundle.route_templates.is_empty() { + if !bundle.route_templates.is_empty() || !bundle.trigger_templates.is_empty() { return Err(ApplyError::Invalid( - "an app manifest cannot declare route_templates — templates live on a \ - group and fan out to its descendant apps" + "an app manifest cannot declare route_templates/trigger_templates — \ + templates live on a group and fan out to its descendant apps" .into(), )); } @@ -847,29 +872,9 @@ impl ApplyService { let app_id = owner.app_id().expect("triggers only exist on app nodes"); let bt = bundle_triggers[&ch.key]; let sid = resolve_script(&name_to_id, bt.script())?; - if let BundleTrigger::Email { - inbound_secret_ref, .. - } = bt - { - // Resolve the referenced secret, decrypt it, and re-seal it - // into the email trigger's detail row — the value never - // travels in the manifest. - let (ct, nonce) = self.resolve_and_seal(app_id, inbound_secret_ref).await?; - insert_email_trigger_tx(&mut *tx, app_id, sid, actor, &ct, &nonce) - .await - .map_err(map_trig)?; - } else { - let (dispatch, retry_max, backoff, base) = self.trigger_settings(bt); - let details = bundle_trigger_details( - bt, - self.trigger_config.queue_default_visibility_timeout_secs, - ); - insert_trigger_tx( - &mut *tx, app_id, sid, actor, dispatch, retry_max, backoff, base, &details, - ) - .await - .map_err(map_trig)?; - } + // Hand-declared trigger — no template stamp. + self.insert_bundle_trigger_tx(&mut *tx, app_id, sid, actor, bt, None) + .await?; report.triggers_created += 1; } @@ -962,6 +967,38 @@ impl ApplyService { } } + // 3e. Trigger templates (§4.5, M4b) — GROUP-owned, like 3d. + if let ApplyOwner::Group(gid) = owner { + let bundle_tt: HashMap = bundle + .trigger_templates + .iter() + .map(|t| (t.name.to_lowercase(), t)) + .collect(); + for ch in &plan.trigger_templates { + match ch.op { + Op::Create => { + let spec = bundle_tt[&ch.key.to_lowercase()]; + crate::template_repo::insert_trigger_tx( + &mut *tx, gid, &spec.name, &spec.spec, + ) + .await + .map_err(map_group_err)?; + report.trigger_templates_created += 1; + } + Op::Update => { + let spec = bundle_tt[&ch.key.to_lowercase()]; + crate::template_repo::update_trigger_tx( + &mut *tx, gid, &spec.name, &spec.spec, + ) + .await + .map_err(map_group_err)?; + report.trigger_templates_updated += 1; + } + Op::NoOp | Op::Delete => {} + } + } + } + // 4. Prune (only with --prune): delete stale triggers, then scripts // (route removals already happened in the route delete-pass above). // Secret pruning is deliberately deferred (destructive + irreversible): @@ -1047,9 +1084,9 @@ impl ApplyService { } } - // Route templates dropped from a group manifest are deleted (their - // expanded routes are reaped by the expansion engine, which sees the - // template gone). Group-only, mirroring the reconcile guard above. + // Route + trigger templates dropped from a group manifest are + // deleted (their expansions are reaped by the expansion engine, + // which sees the template gone). Group-only. if let ApplyOwner::Group(gid) = owner { for ch in &plan.route_templates { if ch.op == Op::Delete { @@ -1059,6 +1096,14 @@ impl ApplyService { report.route_templates_deleted += 1; } } + for ch in &plan.trigger_templates { + if ch.op == Op::Delete { + crate::template_repo::delete_trigger_tx(&mut *tx, gid, &ch.key) + .await + .map_err(map_group_err)?; + report.trigger_templates_deleted += 1; + } + } } } Ok(name_to_id) @@ -1439,6 +1484,20 @@ impl ApplyService { &mut report, ) .await?; + let hand_trigger_ids = bundle_trigger_identities(&p.node.bundle); + self.expand_trigger_templates_tx( + &mut tx, + app_id, + &p.node.slug, + env, + actor, + &p.app_chain, + &name_to_id, + &group_script_index, + &hand_trigger_ids, + &mut report, + ) + .await?; } } } @@ -1464,10 +1523,10 @@ impl ApplyService { // apply. A deleted template's expansions in descendant apps NOT in this // tree survive until those apps are re-applied — warn so the operator // isn't surprised that a pruned template left live routes elsewhere. - if report.route_templates_deleted > 0 { + if report.route_templates_deleted > 0 || report.trigger_templates_deleted > 0 { report.warnings.push( - "deleted route template(s): expanded routes in descendant apps not included in \ - this apply remain until those apps are re-applied (`pic apply --dir` over them)" + "deleted template(s): expansions in descendant apps not included in this apply \ + remain until those apps are re-applied (`pic apply --dir` over them)" .into(), ); } @@ -1903,7 +1962,9 @@ impl ApplyService { // how many app nodes in this apply sit under it (will get expansions). let mut template_blast_radius: Vec = Vec::new(); for n in &bundle.nodes { - if n.kind == NodeKind::Group && !n.bundle.route_templates.is_empty() { + let has_templates = + !n.bundle.route_templates.is_empty() || !n.bundle.trigger_templates.is_empty(); + if n.kind == NodeKind::Group && has_templates { if let Some(gid) = group_id_by_slug.get(&n.slug.to_lowercase()).copied() { let affected_apps = prepared .iter() @@ -2000,6 +2061,50 @@ impl ApplyService { Ok(out) } + /// Insert one concrete trigger (hand-declared or template-expanded) into an + /// app, stamping `from_template`. Email triggers resolve + re-seal the + /// referenced secret per app (the value never travels in a manifest). + async fn insert_bundle_trigger_tx( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + app_id: AppId, + script_id: ScriptId, + actor: AdminUserId, + bt: &BundleTrigger, + from_template: Option, + ) -> Result<(), ApplyError> { + if let BundleTrigger::Email { + inbound_secret_ref, .. + } = bt + { + let (ct, nonce) = self.resolve_and_seal(app_id, inbound_secret_ref).await?; + insert_email_trigger_tx(tx, app_id, script_id, actor, &ct, &nonce, from_template) + .await + .map_err(map_trig)?; + } else { + let (dispatch, retry_max, backoff, base) = self.trigger_settings(bt); + let details = bundle_trigger_details( + bt, + self.trigger_config.queue_default_visibility_timeout_secs, + ); + insert_trigger_tx( + tx, + app_id, + script_id, + actor, + dispatch, + retry_max, + backoff, + base, + &details, + from_template, + ) + .await + .map_err(map_trig)?; + } + Ok(()) + } + /// Resolve a template's `script_name` to an id in `app_id`'s context: the /// app's own (just-reconciled) scripts first, then in-tx group scripts along /// the chain (nearest-first), then committed inherited scripts (pool). This @@ -2029,7 +2134,7 @@ impl ApplyService { { Some(s) => Ok(s.id), None => Err(ApplyError::Invalid(format!( - "route template binds to script `{name}`, which is not declared on the app or any \ + "template binds to script `{name}`, which is not declared on the app or any \ ancestor group" ))), } @@ -2241,6 +2346,171 @@ impl ApplyService { Ok(rows.into_iter().map(|r| (r.identity(), r)).collect()) } + /// Expand the TRIGGER templates visible to one descendant app (§4.5, M4b), + /// the trigger analogue of `expand_route_templates_tx`. Each chain template + /// fans into one concrete trigger, keyed by `from_template` (one trigger per + /// template per app). Idempotent: a template whose resolved semantic + /// identity is unchanged is a no-op; a changed one is delete+recreate; a + /// removed/disabled one is reaped. Collision with a hand-declared trigger + /// identity (or between two templates) is a hard error. + #[allow(clippy::too_many_arguments, clippy::too_many_lines)] + async fn expand_trigger_templates_tx( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + app_id: AppId, + app_slug: &str, + env: Option<&str>, + actor: AdminUserId, + app_chain: &[GroupId], + own_name_to_id: &HashMap, + group_script_index: &HashMap>, + hand_declared_trigger_ids: &HashSet, + report: &mut ApplyReport, + ) -> Result<(), ApplyError> { + let templates = crate::template_repo::list_triggers_for_groups_tx(tx, app_chain) + .await + .map_err(map_group_err)?; + let depth: HashMap = + app_chain.iter().enumerate().map(|(i, g)| (*g, i)).collect(); + let mut by_name: HashMap = HashMap::new(); + for t in &templates { + let lc = t.name.to_lowercase(); + let d = depth.get(&t.group_id).copied().unwrap_or(usize::MAX); + let nearer = by_name + .get(&lc) + .is_none_or(|e| depth.get(&e.group_id).copied().unwrap_or(usize::MAX) > d); + if nearer { + by_name.insert(lc, t); + } + } + + // id→name for the app's chain, so an EXISTING expansion's semantic + // identity can be computed (current_trigger_identity needs the bound + // script's name). App-own names + every chain group's scripts. + let mut id_to_name: HashMap = own_name_to_id + .iter() + .map(|(n, id)| (*id, n.clone())) + .collect(); + for gid in app_chain { + for s in self + .scripts + .list_for_group(*gid) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))? + { + id_to_name.entry(s.id).or_insert_with(|| s.name.clone()); + } + } + + // Existing expansions for this app, keyed by source template id. + let ft_rows: Vec<(Uuid, Uuid)> = sqlx::query_as( + "SELECT id, from_template FROM triggers \ + WHERE app_id = $1 AND from_template IS NOT NULL", + ) + .bind(app_id.into_inner()) + .fetch_all(&mut **tx) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + let by_trigger_id: HashMap = ft_rows + .iter() + .map(|(tid, tmpl)| ((*tid).into(), *tmpl)) + .collect(); + let all_triggers = self + .triggers + .list_for_app(app_id) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + // template_id → (trigger_id, current semantic identity) + let mut existing: HashMap)> = HashMap::new(); + for trg in &all_triggers { + if let Some(tmpl) = by_trigger_id.get(&trg.id) { + existing.insert(*tmpl, (trg.id, current_trigger_identity(trg, &id_to_name))); + } + } + + // Effective vars for `{var:NAME}` (committed state — see route expansion). + let vars = { + let cands = crate::config_resolver::fetch_var_candidates(&self.pool, app_id) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + crate::config_resolver::resolve(cands).0 + }; + + // Desired expansions: template_id → (identity, resolved trigger, script). + let mut desired: HashMap = HashMap::new(); + let mut desired_idents: HashMap = HashMap::new(); + for t in by_name.values() { + let resolved = resolve_placeholders_in_json(&t.spec, app_slug, env, &vars)?; + let bt: BundleTrigger = serde_json::from_value(resolved) + .map_err(|e| ApplyError::Invalid(format!("trigger template `{}`: {e}", t.name)))?; + // Re-validate the RESOLVED trigger with the same per-kind structural + // rules a hand-declared trigger gets (cron schedule, queue timeout, + // email secret ref, …) — placeholders mean this can only run + // post-substitution, so a `{var:…}`-injected bad schedule/timeout + // can't become a stored trigger. + validate_trigger_shape(&bt) + .map_err(|e| ApplyError::Invalid(format!("trigger template `{}`: {e}", t.name)))?; + let identity = bt.identity(); + if hand_declared_trigger_ids.contains(&identity) { + return Err(ApplyError::Invalid(format!( + "trigger template `{}` expands to a trigger the app `{app_slug}` also declares \ + by hand ({identity}); remove one of them", + t.name + ))); + } + if let Some(prev) = desired_idents.get(&identity) { + if *prev != t.id { + return Err(ApplyError::Invalid(format!( + "two trigger templates expand to the same trigger ({identity}) on app \ + `{app_slug}`" + ))); + } + } + let sid = self + .resolve_template_script( + bt.script(), + app_id, + own_name_to_id, + app_chain, + group_script_index, + ) + .await?; + desired_idents.insert(identity.clone(), t.id); + desired.insert(t.id, (identity, bt, sid)); + } + + // Reap expansions whose template is gone. + for (tmpl, (trigger_id, _)) in &existing { + if !desired.contains_key(tmpl) { + delete_trigger_tx(&mut *tx, *trigger_id) + .await + .map_err(map_trig)?; + report.triggers_deleted += 1; + } + } + // Create or replace each desired expansion. + for (tmpl, (identity, bt, sid)) in &desired { + match existing.get(tmpl) { + Some((_, Some(cur))) if cur == identity => {} // unchanged — no-op + Some((trigger_id, _)) => { + delete_trigger_tx(&mut *tx, *trigger_id) + .await + .map_err(map_trig)?; + report.triggers_deleted += 1; + self.insert_bundle_trigger_tx(&mut *tx, app_id, *sid, actor, bt, Some(*tmpl)) + .await?; + report.triggers_created += 1; + } + None => { + self.insert_bundle_trigger_tx(&mut *tx, app_id, *sid, actor, bt, Some(*tmpl)) + .await?; + report.triggers_created += 1; + } + } + } + Ok(()) + } + async fn resolve_app(&self, ident: &str) -> Result { crate::app_repo::resolve_app(self.apps.as_ref(), ident) .await @@ -2750,14 +3020,17 @@ impl ApplyService { .map(|r| (r.key, r.value)) .collect(); let extension_points = self.load_extension_points(owner).await?; - // Route templates are group-owned only (§4.5, M4a). - let route_templates = match owner { - ApplyOwner::Group(group_id) => { + // Route + trigger templates are group-owned only (§4.5, M4a/M4b). + let (route_templates, trigger_templates) = match owner { + ApplyOwner::Group(group_id) => ( crate::template_repo::list_for_group(&self.pool, group_id) .await - .map_err(map_group_err)? - } - ApplyOwner::App(_) => Vec::new(), + .map_err(map_group_err)?, + crate::template_repo::list_triggers_for_group(&self.pool, group_id) + .await + .map_err(map_group_err)?, + ), + ApplyOwner::App(_) => (Vec::new(), Vec::new()), }; Ok(CurrentState { scripts, @@ -2767,6 +3040,7 @@ impl ApplyService { vars, extension_points, route_templates, + trigger_templates, }) } @@ -2897,9 +3171,56 @@ fn compute_diff_with_names( // GROUP route-template diff. On an app node both sides are empty (apps // own no templates); the per-app expansions are appended in prepare_tree. route_templates: diff_route_templates(current, bundle), + trigger_templates: diff_trigger_templates(current, bundle), } } +/// Diff a group's trigger templates by name (case-insensitive), value-sensitive +/// on the stored spec JSON. Mirrors `diff_route_templates`. +fn diff_trigger_templates(current: &CurrentState, bundle: &Bundle) -> Vec { + let live: HashMap = current + .trigger_templates + .iter() + .map(|t| (t.name.to_lowercase(), &t.spec)) + .collect(); + let desired: HashSet = bundle + .trigger_templates + .iter() + .map(|t| t.name.to_lowercase()) + .collect(); + + let mut out = Vec::new(); + for t in &bundle.trigger_templates { + match live.get(&t.name.to_lowercase()) { + Some(cur) if **cur == t.spec => out.push(ResourceChange { + op: Op::NoOp, + key: t.name.clone(), + detail: None, + }), + Some(_) => out.push(ResourceChange { + op: Op::Update, + key: t.name.clone(), + detail: Some("template changed".into()), + }), + None => out.push(ResourceChange { + op: Op::Create, + key: t.name.clone(), + detail: None, + }), + } + } + for cur in ¤t.trigger_templates { + if !desired.contains(&cur.name.to_lowercase()) { + out.push(ResourceChange { + op: Op::Delete, + key: cur.name.clone(), + detail: None, + }); + } + } + out +} + /// Diff a group's route templates by name (case-insensitive), value-sensitive: /// a changed field is an `Update`, a live template absent from the manifest a /// `Delete` (applied under `--prune`). Mirrors `diff_extension_points`. @@ -2987,6 +3308,81 @@ fn validate_route_templates(templates: &[RouteTemplateSpec]) -> Result<(), Apply Ok(()) } +/// Validate a group's trigger templates: each needs a unique name and a `spec` +/// that deserializes to a valid `BundleTrigger`; placeholder tokens in every +/// string leaf must be from the allowed inert set. +fn validate_trigger_templates(templates: &[TriggerTemplateSpec]) -> Result<(), ApplyError> { + let mut seen: HashSet = HashSet::new(); + for t in templates { + if t.name.trim().is_empty() { + return Err(ApplyError::Invalid( + "a trigger template needs a name".into(), + )); + } + if !seen.insert(t.name.to_lowercase()) { + return Err(ApplyError::Invalid(format!( + "duplicate trigger template name `{}`", + t.name + ))); + } + // Shape check: the spec must be a real BundleTrigger (kind + params). + serde_json::from_value::(t.spec.clone()).map_err(|e| { + ApplyError::Invalid(format!( + "trigger template `{}` is not a valid trigger: {e}", + t.name + )) + })?; + scan_placeholders_in_json(&t.spec, &mut validate_placeholder_token)?; + } + Ok(()) +} + +/// Recursively invoke `f` on each `{…}` placeholder token in every string leaf +/// of a JSON value (object values + array elements). +fn scan_placeholders_in_json( + v: &serde_json::Value, + f: &mut impl FnMut(&str) -> Result<(), ApplyError>, +) -> Result<(), ApplyError> { + match v { + serde_json::Value::String(s) => scan_placeholders(s, &mut *f), + serde_json::Value::Array(a) => a.iter().try_for_each(|e| scan_placeholders_in_json(e, f)), + serde_json::Value::Object(o) => { + o.values().try_for_each(|e| scan_placeholders_in_json(e, f)) + } + _ => Ok(()), + } +} + +/// Recursively substitute placeholders in every string leaf of a JSON value. +fn resolve_placeholders_in_json( + v: &serde_json::Value, + app_slug: &str, + env: Option<&str>, + vars: &std::collections::BTreeMap, +) -> Result { + Ok(match v { + serde_json::Value::String(s) => { + serde_json::Value::String(resolve_placeholders(s, app_slug, env, vars)?) + } + serde_json::Value::Array(a) => serde_json::Value::Array( + a.iter() + .map(|e| resolve_placeholders_in_json(e, app_slug, env, vars)) + .collect::>()?, + ), + serde_json::Value::Object(o) => serde_json::Value::Object( + o.iter() + .map(|(k, e)| { + Ok(( + k.clone(), + resolve_placeholders_in_json(e, app_slug, env, vars)?, + )) + }) + .collect::>()?, + ), + other => other.clone(), + }) +} + /// Invoke `f` on the inner token of each `{…}` placeholder in `s`. An unbalanced /// `{` is a hard error (so a typo can't silently pass through to a live route). fn scan_placeholders( @@ -3805,6 +4201,16 @@ fn current_trigger_identity(t: &Trigger, name_by_id: &HashMap) } } +/// The semantic identities of a bundle's HAND-declared triggers — the set a +/// trigger-template expansion is collision-checked against (§4.5, M4b). +fn bundle_trigger_identities(bundle: &Bundle) -> HashSet { + bundle + .triggers + .iter() + .map(BundleTrigger::identity) + .collect() +} + /// The route-identity keys of a bundle's HAND-declared routes — the set a /// template expansion is collision-checked against (§4.5, M4a). fn bundle_route_keys(bundle: &Bundle) -> HashSet { @@ -4065,6 +4471,14 @@ pub struct ApplyReport { pub route_templates_updated: u32, #[serde(default)] pub route_templates_deleted: u32, + /// Trigger-template rows reconciled on group nodes (§4.5, M4b). Per-app + /// trigger EXPANSIONS are counted in `triggers_created` / `triggers_deleted`. + #[serde(default)] + pub trigger_templates_created: u32, + #[serde(default)] + pub trigger_templates_updated: u32, + #[serde(default)] + pub trigger_templates_deleted: u32, #[serde(skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, } @@ -4395,6 +4809,14 @@ fn state_token_with_names( t.enabled, )); } + // Trigger templates (group-owned, §4.5 M4b): value-sensitive on the spec. + for t in ¤t.trigger_templates { + parts.push(format!( + "tt|{}|{}", + t.name.to_lowercase(), + serde_json::to_string(&t.spec).unwrap_or_default() + )); + } // Order-independent: sort the per-resource tokens before hashing. parts.sort_unstable(); let mut h: u64 = 0xcbf2_9ce4_8422_2325; @@ -4477,6 +4899,7 @@ mod tests { vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), route_templates: Vec::new(), + trigger_templates: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts.len(), 1); @@ -4501,6 +4924,7 @@ mod tests { vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), route_templates: Vec::new(), + trigger_templates: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert!(plan.is_noop(), "expected all no-op, got {plan:?}"); @@ -4520,6 +4944,7 @@ mod tests { vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), route_templates: Vec::new(), + trigger_templates: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts[0].op, Op::Update); @@ -4540,6 +4965,7 @@ mod tests { vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), route_templates: Vec::new(), + trigger_templates: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts[0].op, Op::Delete); @@ -4588,6 +5014,7 @@ mod tests { vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), route_templates: Vec::new(), + trigger_templates: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.routes[0].op, Op::Update); @@ -4602,6 +5029,7 @@ mod tests { vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), route_templates: Vec::new(), + trigger_templates: Vec::new(), } } @@ -4964,6 +5392,25 @@ mod tests { assert!(validate_placeholder_token("var:bad name").is_err()); } + #[test] + fn resolve_placeholders_in_json_substitutes_string_leaves() { + let mut vars = std::collections::BTreeMap::new(); + vars.insert("topic".to_string(), serde_json::json!("billing")); + let spec = serde_json::json!({ + "kind": "kv", + "script": "shared", + "collection_glob": "{app_slug}-data", + "ops": ["insert"], + "nested": { "pattern": "{var:topic}.*" }, + }); + let out = resolve_placeholders_in_json(&spec, "blog", None, &vars).unwrap(); + assert_eq!(out["collection_glob"], serde_json::json!("blog-data")); + assert_eq!(out["nested"]["pattern"], serde_json::json!("billing.*")); + assert_eq!(out["ops"], serde_json::json!(["insert"])); + let bad = serde_json::json!({ "x": "{var:nope}" }); + assert!(resolve_placeholders_in_json(&bad, "blog", None, &vars).is_err()); + } + #[test] fn validate_route_templates_catches_dups_and_bad_placeholders() { let mk = |name: &str, path: &str| RouteTemplateSpec { diff --git a/crates/manager-core/src/template_repo.rs b/crates/manager-core/src/template_repo.rs index f817204..939fcdd 100644 --- a/crates/manager-core/src/template_repo.rs +++ b/crates/manager-core/src/template_repo.rs @@ -198,3 +198,121 @@ pub(crate) async fn delete_tx( .await?; Ok(()) } + +// ---------------------------------------------------------------------------- +// Trigger templates (§4.5, M4b). The kind-specific params vary, so the whole +// `BundleTrigger` wire object is stored as `spec` JSONB (placeholders +// unresolved); the apply rebuilds + resolves it per descendant. +// ---------------------------------------------------------------------------- + +/// A persisted trigger template: name + the raw (unresolved) `BundleTrigger` +/// wire object. +#[derive(Debug, Clone)] +pub struct TriggerTemplate { + pub id: Uuid, + pub group_id: GroupId, + pub name: String, + pub spec: serde_json::Value, +} + +#[derive(sqlx::FromRow)] +struct TriggerTemplateRow { + id: Uuid, + group_id: Uuid, + name: String, + spec: serde_json::Value, +} + +impl From for TriggerTemplate { + fn from(r: TriggerTemplateRow) -> Self { + Self { + id: r.id, + group_id: r.group_id.into(), + name: r.name, + spec: r.spec, + } + } +} + +/// A group's OWN trigger templates (read/diff path). +pub(crate) async fn list_triggers_for_group( + pool: &PgPool, + group_id: GroupId, +) -> Result, Error> { + let rows = sqlx::query_as::<_, TriggerTemplateRow>( + "SELECT id, group_id, name, spec FROM trigger_templates \ + WHERE group_id = $1 ORDER BY LOWER(name)", + ) + .bind(group_id.into_inner()) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(Into::into).collect()) +} + +/// Every trigger template owned by any group in `group_ids` (expansion path). +pub(crate) async fn list_triggers_for_groups_tx( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + group_ids: &[GroupId], +) -> Result, Error> { + if group_ids.is_empty() { + return Ok(Vec::new()); + } + let ids: Vec = group_ids.iter().map(|g| g.into_inner()).collect(); + let rows = sqlx::query_as::<_, TriggerTemplateRow>( + "SELECT id, group_id, name, spec FROM trigger_templates \ + WHERE group_id = ANY($1) ORDER BY LOWER(name)", + ) + .bind(&ids) + .fetch_all(&mut **tx) + .await?; + Ok(rows.into_iter().map(Into::into).collect()) +} + +/// Insert a new trigger template (diff Op::Create). +pub(crate) async fn insert_trigger_tx( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + group_id: GroupId, + name: &str, + spec: &serde_json::Value, +) -> Result<(), Error> { + sqlx::query("INSERT INTO trigger_templates (group_id, name, spec) VALUES ($1, $2, $3)") + .bind(group_id.into_inner()) + .bind(name) + .bind(spec) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// Replace a trigger template's spec, keyed by `(group, lower(name))` (Update). +pub(crate) async fn update_trigger_tx( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + group_id: GroupId, + name: &str, + spec: &serde_json::Value, +) -> Result<(), Error> { + sqlx::query( + "UPDATE trigger_templates SET spec = $3, updated_at = NOW() \ + WHERE group_id = $1 AND LOWER(name) = LOWER($2)", + ) + .bind(group_id.into_inner()) + .bind(name) + .bind(spec) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// Delete a trigger template by name (Delete under `--prune`). +pub(crate) async fn delete_trigger_tx( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + group_id: GroupId, + name: &str, +) -> Result<(), Error> { + sqlx::query("DELETE FROM trigger_templates WHERE group_id = $1 AND LOWER(name) = LOWER($2)") + .bind(group_id.into_inner()) + .bind(name) + .execute(&mut **tx) + .await?; + Ok(()) +} diff --git a/crates/manager-core/src/trigger_repo.rs b/crates/manager-core/src/trigger_repo.rs index 584a828..7e50712 100644 --- a/crates/manager-core/src/trigger_repo.rs +++ b/crates/manager-core/src/trigger_repo.rs @@ -519,6 +519,7 @@ pub(crate) async fn insert_trigger_tx( retry_backoff: BackoffShape, retry_base_ms: u32, details: &TriggerDetails, + from_template: Option, ) -> Result { let kind = match details { TriggerDetails::Kv { .. } => "kv", @@ -562,8 +563,8 @@ pub(crate) async fn insert_trigger_tx( "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, $3, TRUE, $4, $5, $6, $7, $8) RETURNING id", + registered_by_principal, from_template \ + ) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, $9) RETURNING id", ) .bind(app_id.into_inner()) .bind(script_id.into_inner()) @@ -573,6 +574,7 @@ pub(crate) async fn insert_trigger_tx( .bind(retry_backoff.as_str()) .bind(i32::try_from(retry_base_ms).unwrap_or(1000)) .bind(registered_by.into_inner()) + .bind(from_template) .fetch_one(&mut **tx) .await?; let tid = row.0; @@ -679,17 +681,19 @@ pub(crate) async fn insert_email_trigger_tx( registered_by: AdminUserId, inbound_secret_encrypted: &[u8], inbound_secret_nonce: &[u8], + from_template: Option, ) -> Result { let row: (Uuid,) = 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, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id", + registered_by_principal, from_template \ + ) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id", ) .bind(app_id.into_inner()) .bind(script_id.into_inner()) .bind(registered_by.into_inner()) + .bind(from_template) .fetch_one(&mut **tx) .await?; sqlx::query( diff --git a/crates/manager-core/tests/expected_schema.txt b/crates/manager-core/tests/expected_schema.txt index 8ebb7dd..9b9864f 100644 --- a/crates/manager-core/tests/expected_schema.txt +++ b/crates/manager-core/tests/expected_schema.txt @@ -370,6 +370,14 @@ table: topics created_at: timestamp with time zone NOT NULL default=now() updated_at: timestamp with time zone NOT NULL default=now() +table: trigger_templates + id: uuid NOT NULL default=gen_random_uuid() + group_id: uuid NOT NULL + name: text NOT NULL + spec: jsonb NOT NULL + created_at: timestamp with time zone NOT NULL default=now() + updated_at: timestamp with time zone NOT NULL default=now() + table: triggers id: uuid NOT NULL default=gen_random_uuid() app_id: uuid NOT NULL @@ -384,6 +392,7 @@ table: triggers created_at: timestamp with time zone NOT NULL default=now() updated_at: timestamp with time zone NOT NULL default=now() name: text NOT NULL default=(gen_random_uuid())::text + from_template: uuid NULL table: vars id: uuid NOT NULL default=gen_random_uuid() @@ -582,11 +591,16 @@ indexes on secrets: indexes on topics: topics_pkey: public.topics USING btree (app_id, name) +indexes on trigger_templates: + trigger_templates_group_name_idx: public.trigger_templates USING btree (group_id, lower(name)) + trigger_templates_pkey: public.trigger_templates USING btree (id) + indexes on triggers: idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true) idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text)) idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true) triggers_app_name_uniq: public.triggers USING btree (app_id, name) + triggers_from_template_idx: public.triggers USING btree (app_id, from_template) triggers_pkey: public.triggers USING btree (id) indexes on vars: @@ -802,6 +816,10 @@ constraints on topics: [FOREIGN KEY] topics_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE [PRIMARY KEY] topics_pkey: PRIMARY KEY (app_id, name) +constraints on trigger_templates: + [FOREIGN KEY] trigger_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE + [PRIMARY KEY] trigger_templates_pkey: PRIMARY KEY (id) + constraints on triggers: [CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text]))) [CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text, 'queue'::text]))) @@ -871,3 +889,4 @@ constraints on vars: 0051: extension points 0052: owner project 0053: templates + 0054: trigger templates diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index d2dbeaa..14581ff 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1426,6 +1426,8 @@ pub struct NodePlanDto { pub extension_points: Vec, #[serde(default)] pub route_templates: Vec, + #[serde(default)] + pub trigger_templates: Vec, } /// Response of `POST .../apply`: counts of what changed. @@ -1476,6 +1478,12 @@ pub struct ApplyReportDto { #[serde(default)] pub route_templates_deleted: u32, #[serde(default)] + pub trigger_templates_created: u32, + #[serde(default)] + pub trigger_templates_updated: u32, + #[serde(default)] + pub trigger_templates_deleted: u32, + #[serde(default)] pub warnings: Vec, } diff --git a/crates/picloud-cli/src/cmds/apply.rs b/crates/picloud-cli/src/cmds/apply.rs index a1eb38e..ccd74bc 100644 --- a/crates/picloud-cli/src/cmds/apply.rs +++ b/crates/picloud-cli/src/cmds/apply.rs @@ -233,6 +233,20 @@ pub async fn run_tree( ), ); } + if report.trigger_templates_created > 0 + || report.trigger_templates_updated > 0 + || report.trigger_templates_deleted > 0 + { + block.field( + "trigger-templates", + format!( + "+{} ~{} -{}", + report.trigger_templates_created, + report.trigger_templates_updated, + report.trigger_templates_deleted + ), + ); + } for w in &report.warnings { block.field("warning", w.clone()); } diff --git a/crates/picloud-cli/src/cmds/init.rs b/crates/picloud-cli/src/cmds/init.rs index ab64648..b02b076 100644 --- a/crates/picloud-cli/src/cmds/init.rs +++ b/crates/picloud-cli/src/cmds/init.rs @@ -144,6 +144,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest { vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), route_templates: Vec::new(), + trigger_templates: Vec::new(), } } diff --git a/crates/picloud-cli/src/cmds/plan.rs b/crates/picloud-cli/src/cmds/plan.rs index f534c9c..69d62a9 100644 --- a/crates/picloud-cli/src/cmds/plan.rs +++ b/crates/picloud-cli/src/cmds/plan.rs @@ -67,7 +67,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) { let mut table = Table::new(["node", "kind", "op", "resource", "detail"]); for n in &plan.nodes { let node = format!("{}:{}", n.kind, n.slug); - let groups: [(&str, &Vec); 7] = [ + let groups: [(&str, &Vec); 8] = [ ("script", &n.scripts), ("route", &n.routes), ("trigger", &n.triggers), @@ -75,6 +75,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) { ("var", &n.vars), ("extension-point", &n.extension_points), ("route-template", &n.route_templates), + ("trigger-template", &n.trigger_templates), ]; for (rk, changes) in groups { for c in changes { @@ -206,6 +207,15 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result { .map(serde_json::to_value) .collect::, _>>()?; + // Trigger templates (§4.5, M4b): name + the flattened trigger spec + // (kind/script/params) — the server's `TriggerTemplateSpec` deserializes + // `{ name, }`. Group manifests only. + let trigger_templates = manifest + .trigger_templates + .iter() + .map(serde_json::to_value) + .collect::, _>>()?; + Ok(json!({ "scripts": scripts, "routes": routes, @@ -214,6 +224,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result { "vars": vars, "extension_points": extension_points, "route_templates": route_templates, + "trigger_templates": trigger_templates, })) } diff --git a/crates/picloud-cli/src/cmds/pull.rs b/crates/picloud-cli/src/cmds/pull.rs index dd145ac..8f4eca8 100644 --- a/crates/picloud-cli/src/cmds/pull.rs +++ b/crates/picloud-cli/src/cmds/pull.rs @@ -237,8 +237,9 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> }, vars: manifest_vars, extension_points, - // `pull` is app-scoped; route templates are group-owned (§4.5, M4a). + // `pull` is app-scoped; templates are group-owned (§4.5). route_templates: Vec::new(), + trigger_templates: Vec::new(), }; std::fs::write(&manifest_path, manifest.to_toml()?) diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index 5bba959..bfa5abe 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -58,6 +58,12 @@ pub struct Manifest { /// fields may carry `{app_slug}`/`{env}`/`{var:NAME}` placeholders. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub route_templates: Vec, + /// `[[trigger_templates]]` — GROUP-only trigger declarations that fan out per + /// descendant app (§4.5, M4b). Each entry is `name = …`, `kind = "cron"|…`, + /// `script = …`, plus the kind's params (string fields may carry + /// placeholders). Kept loosely-typed so one block covers all seven kinds. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub trigger_templates: Vec, } impl Manifest { @@ -306,6 +312,17 @@ pub struct ManifestRouteTemplate { pub enabled: bool, } +/// `[[trigger_templates]]` — a trigger declared once on a group, fanned out per +/// descendant app (§4.5, M4b). `name` is the per-group identity; the remaining +/// keys (`kind`, `script`, kind params) are kept as a flattened table so one +/// block covers every kind, matching the server's `{ name, }`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ManifestTriggerTemplate { + pub name: String, + #[serde(flatten)] + pub spec: toml::Value, +} + /// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …). #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ManifestTriggers { @@ -543,6 +560,7 @@ mod tests { default: Some("default-theme".into()), }], route_templates: Vec::new(), + trigger_templates: Vec::new(), } } @@ -648,6 +666,7 @@ mod tests { vars: BTreeMap::new(), extension_points: Vec::new(), route_templates: Vec::new(), + trigger_templates: Vec::new(), }; let text = m.to_toml().unwrap(); assert!(!text.contains("[[scripts]]"), "got:\n{text}"); diff --git a/crates/picloud-cli/tests/templates.rs b/crates/picloud-cli/tests/templates.rs index 2811aa9..19ca904 100644 --- a/crates/picloud-cli/tests/templates.rs +++ b/crates/picloud-cli/tests/templates.rs @@ -208,6 +208,119 @@ fn expansion_colliding_with_hand_declared_route_is_rejected() { ); } +/// Template-expanded kv triggers for the two apps, as `(collection_glob, id)`, +/// read straight from Postgres (a group-script trigger isn't listable via the +/// app trigger API). Scoped to the test's two slugs. +fn trigger_rows(a: &str, b: &str) -> Vec<(String, String)> { + let url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); + let mut pg = PgClient::connect(&url, NoTls).expect("pg connect"); + let want = [format!("{a}-data"), format!("{b}-data")]; + let rows = pg + .query( + "SELECT d.collection_glob, t.id::text FROM triggers t \ + JOIN kv_trigger_details d ON d.trigger_id = t.id \ + WHERE t.from_template IS NOT NULL AND d.collection_glob = ANY($1) \ + ORDER BY d.collection_glob", + &[&&want[..]], + ) + .expect("query trigger expansions"); + rows.iter().map(|r| (r.get(0), r.get(1))).collect() +} + +const TRIGGER_TEMPLATE: &str = "[[trigger_templates]]\nname = \"ev\"\nkind = \"kv\"\n\ + script = \"shared\"\ncollection_glob = \"{app_slug}-data\"\n\ + ops = [\"insert\"]\n"; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn trigger_template_fans_out_per_app_idempotently_then_prunes() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let group = common::unique_slug("ttpl-g"); + let a = common::unique_slug("ttpl-a"); + let b = common::unique_slug("ttpl-b"); + + let _g = GroupGuard::new(&env.url, &env.token, &group); + common::pic_as(&env) + .args(["groups", "create", &group]) + .assert() + .success(); + let _aa = AppGuard::new(&env.url, &env.token, &a); + let _ab = AppGuard::new(&env.url, &env.token, &b); + for slug in [&a, &b] { + common::pic_as(&env) + .args(["apps", "create", slug, "--group", &group]) + .assert() + .success(); + } + + // group manifest: a `shared` script + a kv trigger template using {app_slug}. + let dir = TempDir::new().expect("tempdir"); + fs::create_dir_all(dir.path().join("scripts")).unwrap(); + fs::write(dir.path().join("scripts/shared.rhai"), r#""x""#).unwrap(); + let write_group = |with_tmpl: bool| { + let tmpl = if with_tmpl { TRIGGER_TEMPLATE } else { "" }; + fs::write( + dir.path().join("picloud.toml"), + format!( + "[group]\nslug = \"{group}\"\nname = \"TT\"\n\n\ + [[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{tmpl}" + ), + ) + .unwrap(); + }; + write_group(true); + for slug in [&a, &b] { + fs::create_dir_all(dir.path().join(slug)).unwrap(); + fs::write( + dir.path().join(slug).join("picloud.toml"), + format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n"), + ) + .unwrap(); + } + + common::pic_as(&env) + .args(["apply", "--dir"]) + .arg(dir.path()) + .assert() + .success(); + let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group)); + + let first = trigger_rows(&a, &b); + assert_eq!( + first.iter().map(|(g, _)| g.as_str()).collect::>(), + vec![format!("{a}-data").as_str(), format!("{b}-data").as_str()], + "each app gets its own {{app_slug}}-resolved kv trigger" + ); + + // Idempotent re-apply: same rows, same ids. + common::pic_as(&env) + .args(["apply", "--dir"]) + .arg(dir.path()) + .assert() + .success(); + assert_eq!( + trigger_rows(&a, &b), + first, + "re-apply must not churn triggers" + ); + + // Remove the template + --prune: trigger expansions reaped. + write_group(false); + common::pic_as(&env) + .args(["apply", "--dir"]) + .arg(dir.path()) + .args(["--prune", "--yes"]) + .assert() + .success(); + assert!( + trigger_rows(&a, &b).is_empty(), + "removing the trigger template must reap its expansions" + ); +} + fn group_script_id(env: &common::TestEnv, group: &str) -> String { let ls = common::pic_as(env) .args(["scripts", "ls", "--group", group]) diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index 5fbdb77..331db11 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -379,27 +379,35 @@ Two distinct constraints: > needing the same declaration = 100× the declarations. The fix is group trigger/route **templates** > that fan out per descendant (a *template/instantiation* mechanism, not inheritance). > -> **Status: ROUTE templates ✅ shipped (M4a, 2026-06-28).** Migration `0053_templates.sql` adds a -> `route_templates` table (owned by a group) and a `routes.from_template` provenance column. A -> `[group]` manifest declares `[[route_templates]]`; `pic apply --dir` reconciles the template rows +> **Status: ✅ shipped — routes (M4a) AND triggers (M4b), 2026-06-28.** Migrations `0053_templates.sql` +> (`route_templates` + `routes.from_template`) and `0054_trigger_templates.sql` (`trigger_templates` + +> `triggers.from_template`). A `[group]` manifest declares `[[route_templates]]` and/or +> `[[trigger_templates]]` (the latter a flat block: `name`, `kind`, `script`, + kind params, covering +> all seven kinds); `pic apply --dir` reconciles the template rows > (group-only — an app declaring one is a 422) and, in tree Phase B, **fans each template out into one -> concrete `routes` row per descendant app node** in the apply, resolving the script binding +> concrete `routes`/`triggers` row per descendant app node** in the apply, resolving the script binding > nearest-owner-wins and substituting an inert placeholder set — `{app_slug}`, `{env}` (from the > apply's selected env), `{var:NAME}` (the app's effective var; an unknown var/placeholder is a hard -> error). Expansion is **idempotent via `from_template`** (diffed create/update-as-replace/delete, so -> re-apply doesn't churn and a removed/disabled template reaps its routes under `--prune`); an -> expansion colliding with a hand-declared route, or two templates colliding, is a hard error. `plan` -> reports the **blast radius** (descendant app count) and the `state_token` folds each template, so an -> edit between plan and apply trips `StateMoved`. Authz: declaring a template needs `GroupScriptsWrite`; -> an app that *receives* expansions needs `AppWriteRoute` (gated even when the app declares no routes -> of its own). Each RESOLVED expansion is validated with the same rules a hand-declared route gets — -> reserved-path rejection, structural path/host parse, and host-claim check — so a `{var:…}`-injected -> reserved path or an unclaimed strict host can't slip in. **Scope:** expansion targets app NODES -> present in the tree apply (the common `pic apply --dir` case), not yet every descendant in another -> repo — deleting a template leaves expansions in out-of-apply descendants until they're re-applied -> (the apply warns); `{var:NAME}` resolves against *committed* vars (a var set in the same apply lands -> next apply). **Trigger templates (M4b) remain** — they reuse this engine over the `triggers` insert -> path (the seven kinds + email-secret sealing). +> error). For triggers the placeholders resolve in every string leaf of the spec, then the typed +> trigger is rebuilt and inserted through the normal path (email secrets are resolved + re-sealed per +> app — the value never travels in a manifest). Expansion is **idempotent via `from_template`** (routes +> diff by identity create/update-as-replace/delete; triggers key one-per-template-per-app and compare +> semantic identity, so re-apply doesn't churn and a removed/disabled template reaps its expansions +> under `--prune`); an expansion colliding with a hand-declared route/trigger, or two templates +> colliding, is a hard error. `plan` reports the **blast radius** (descendant app count) and the +> `state_token` folds each template, so an edit between plan and apply trips `StateMoved`. Authz: +> declaring a template needs `GroupScriptsWrite`; an app that *receives* expansions needs +> `AppWriteRoute` / `AppManageTriggers` (and `AppSecretsRead` for an email-trigger template, which +> seals the recipient's secret) — gated even when the app declares none of its own. Each RESOLVED +> expansion is validated with the same rules its hand-declared counterpart gets — routes: +> reserved-path rejection + structural path/host parse + host-claim; triggers: the per-kind shape +> check (cron schedule, queue timeout, …) — so a `{var:…}`-injected reserved path, unclaimed strict +> host, or malformed schedule/timeout can't slip in. **Scope:** expansion targets app NODES present in the tree apply +> (the common `pic apply --dir` case), not yet every descendant in another repo — deleting a template +> leaves expansions in out-of-apply descendants until they're re-applied (the apply warns); +> `{var:NAME}` resolves against *committed* vars (a var set in the same apply lands next apply). A +> trigger template whose only change is a non-identity field (e.g. dispatch_mode) is a no-op on +> re-apply, mirroring the existing trigger-identity limitation (recreate to change those). ### 4.6 Secrets & `pull`