feat(hierarchies): trigger templates — per-app fan-out (M4b)

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 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-28 17:38:04 +02:00
parent 0396866698
commit 4d2eed4e81
14 changed files with 953 additions and 98 deletions

View File

@@ -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.<kind>]]`; 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);

View File

@@ -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<bool, ApplyError> {
) -> Result<Recipient, ApplyError> {
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<GroupId> = 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.

View File

@@ -94,6 +94,11 @@ pub struct Bundle {
/// (and rejected) on an app node — an app declares concrete `routes`.
#[serde(default)]
pub route_templates: Vec<RouteTemplateSpec>,
/// 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<TriggerTemplateSpec>,
}
/// 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<ResourceChange>,
/// Trigger-template changes on a GROUP node (§4.5, M4b).
#[serde(default)]
pub trigger_templates: Vec<ResourceChange>,
}
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<crate::template_repo::RouteTemplate>,
/// A GROUP owner's OWN trigger templates (§4.5, M4b).
pub trigger_templates: Vec<crate::template_repo::TriggerTemplate>,
}
// ----------------------------------------------------------------------------
@@ -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_templatestemplates 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<String, &TriggerTemplateSpec> = 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<TemplateBlastRadius> = 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<Uuid>,
) -> 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<String, ScriptId>,
group_script_index: &HashMap<GroupId, HashMap<String, ScriptId>>,
hand_declared_trigger_ids: &HashSet<String>,
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<GroupId, usize> =
app_chain.iter().enumerate().map(|(i, g)| (*g, i)).collect();
let mut by_name: HashMap<String, &crate::template_repo::TriggerTemplate> = 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<ScriptId, String> = 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<TriggerId, Uuid> = 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<Uuid, (TriggerId, Option<String>)> = 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<Uuid, (String, BundleTrigger, ScriptId)> = HashMap::new();
let mut desired_idents: HashMap<String, Uuid> = 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<picloud_shared::App, ApplyError> {
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<ResourceChange> {
let live: HashMap<String, &serde_json::Value> = current
.trigger_templates
.iter()
.map(|t| (t.name.to_lowercase(), &t.spec))
.collect();
let desired: HashSet<String> = 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 &current.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<String> = 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::<BundleTrigger>(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<String, serde_json::Value>,
) -> Result<serde_json::Value, ApplyError> {
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::<Result<_, _>>()?,
),
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::<Result<_, ApplyError>>()?,
),
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<ScriptId, String>)
}
}
/// 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<String> {
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<String> {
@@ -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<String>,
}
@@ -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 &current.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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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 {

View File

@@ -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<TriggerTemplateRow> 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<Vec<TriggerTemplate>, 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<Vec<TriggerTemplate>, Error> {
if group_ids.is_empty() {
return Ok(Vec::new());
}
let ids: Vec<Uuid> = 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(())
}

View File

@@ -519,6 +519,7 @@ pub(crate) async fn insert_trigger_tx(
retry_backoff: BackoffShape,
retry_base_ms: u32,
details: &TriggerDetails,
from_template: Option<Uuid>,
) -> Result<TriggerId, TriggerRepoError> {
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<Uuid>,
) -> Result<TriggerId, TriggerRepoError> {
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(

View File

@@ -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

View File

@@ -1426,6 +1426,8 @@ pub struct NodePlanDto {
pub extension_points: Vec<ChangeDto>,
#[serde(default)]
pub route_templates: Vec<ChangeDto>,
#[serde(default)]
pub trigger_templates: Vec<ChangeDto>,
}
/// 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<String>,
}

View File

@@ -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());
}

View File

@@ -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(),
}
}

View File

@@ -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<ChangeDto>); 7] = [
let groups: [(&str, &Vec<ChangeDto>); 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<Value> {
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()?;
// Trigger templates (§4.5, M4b): name + the flattened trigger spec
// (kind/script/params) — the server's `TriggerTemplateSpec` deserializes
// `{ name, <BundleTrigger> }`. Group manifests only.
let trigger_templates = manifest
.trigger_templates
.iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()?;
Ok(json!({
"scripts": scripts,
"routes": routes,
@@ -214,6 +224,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
"vars": vars,
"extension_points": extension_points,
"route_templates": route_templates,
"trigger_templates": trigger_templates,
}))
}

View File

@@ -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()?)

View File

@@ -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<ManifestRouteTemplate>,
/// `[[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<ManifestTriggerTemplate>,
}
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, <BundleTrigger> }`.
#[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}");

View File

@@ -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<_>>(),
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])