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:
@@ -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.
|
||||
|
||||
@@ -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_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<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 ¤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<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 ¤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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user