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:
33
crates/manager-core/migrations/0054_trigger_templates.sql
Normal file
33
crates/manager-core/migrations/0054_trigger_templates.sql
Normal 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);
|
||||||
@@ -266,12 +266,13 @@ async fn authz_tree(
|
|||||||
Some(prune) => {
|
Some(prune) => {
|
||||||
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
|
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
|
||||||
.await?;
|
.await?;
|
||||||
// Template expansion (§4.5, M4a) writes routes INTO this
|
// Template expansion (§4.5) writes routes/triggers INTO
|
||||||
// app, so the actor needs AppWriteRoute when the app's
|
// this app, so the actor needs the matching write cap
|
||||||
// chain carries route templates — even if the app itself
|
// when the app's chain carries templates — even if the
|
||||||
// declares no routes. Without this, expanding routes into
|
// app itself declares none. Without this, expanding into
|
||||||
// an app a principal can't write would slip the gate.
|
// 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(
|
require(
|
||||||
svc.authz.as_ref(),
|
svc.authz.as_ref(),
|
||||||
principal,
|
principal,
|
||||||
@@ -280,6 +281,27 @@ async fn authz_tree(
|
|||||||
.await
|
.await
|
||||||
.map_err(map_authz)?;
|
.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 => {
|
None => {
|
||||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||||
@@ -486,12 +508,13 @@ async fn require_group_node_writes(
|
|||||||
bundle: &Bundle,
|
bundle: &Bundle,
|
||||||
prune: bool,
|
prune: bool,
|
||||||
) -> Result<(), ApplyError> {
|
) -> Result<(), ApplyError> {
|
||||||
// Extension points and route templates gate on the script-write tier (see
|
// Extension points and route/trigger templates gate on the script-write
|
||||||
// the app variant) — both are code-binding declarations, not viewer-tier.
|
// tier (see the app variant) — all are code-binding declarations.
|
||||||
if prune
|
if prune
|
||||||
|| !bundle.scripts.is_empty()
|
|| !bundle.scripts.is_empty()
|
||||||
|| !bundle.extension_points.is_empty()
|
|| !bundle.extension_points.is_empty()
|
||||||
|| !bundle.route_templates.is_empty()
|
|| !bundle.route_templates.is_empty()
|
||||||
|
|| !bundle.trigger_templates.is_empty()
|
||||||
{
|
{
|
||||||
require(
|
require(
|
||||||
svc.authz.as_ref(),
|
svc.authz.as_ref(),
|
||||||
@@ -513,22 +536,36 @@ async fn require_group_node_writes(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Will route-template expansion (§4.5, M4a) write routes into `app_id` during
|
/// What template expansion (§4.5) will write into `app_id` during this tree
|
||||||
/// this tree apply? True iff the app's ancestor chain carries a route template,
|
/// apply. Returns `Recipient { routes, triggers, email }` — whether the app's
|
||||||
/// counting both committed templates and ones declared by a group node in THIS
|
/// ancestor chain carries a route / trigger / EMAIL-trigger template (committed,
|
||||||
/// bundle. Drives the `AppWriteRoute` gate for expansion recipients.
|
/// 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(
|
async fn app_receives_template_expansions(
|
||||||
svc: &ApplyService,
|
svc: &ApplyService,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
bundle: &TreeBundle,
|
bundle: &TreeBundle,
|
||||||
) -> Result<bool, ApplyError> {
|
) -> Result<Recipient, ApplyError> {
|
||||||
let Some(app) = svc
|
let Some(app) = svc
|
||||||
.apps
|
.apps
|
||||||
.get_by_id(app_id)
|
.get_by_id(app_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
else {
|
else {
|
||||||
return Ok(false);
|
return Ok(Recipient::default());
|
||||||
};
|
};
|
||||||
let ancestors = svc
|
let ancestors = svc
|
||||||
.groups
|
.groups
|
||||||
@@ -537,33 +574,55 @@ async fn app_receives_template_expansions(
|
|||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
let ancestor_ids: std::collections::HashSet<GroupId> = ancestors.iter().map(|g| g.id).collect();
|
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.
|
// Committed templates on any ancestor group.
|
||||||
for gid in &ancestor_ids {
|
for gid in &ancestor_ids {
|
||||||
if !crate::template_repo::list_for_group(&svc.pool, *gid)
|
if !r.routes
|
||||||
.await
|
&& !crate::template_repo::list_for_group(&svc.pool, *gid)
|
||||||
.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)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
{
|
.is_empty()
|
||||||
if ancestor_ids.contains(&g.id) {
|
{
|
||||||
return Ok(true);
|
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.
|
/// 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`.
|
/// (and rejected) on an app node — an app declares concrete `routes`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub route_templates: Vec<RouteTemplateSpec>,
|
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
|
/// A route template: a route declared once on a group, expanded into one
|
||||||
@@ -107,6 +112,19 @@ pub struct RouteTemplateSpec {
|
|||||||
pub route: BundleRoute,
|
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
|
/// Desired extension point — a module name opened for dynamic, per-tenant
|
||||||
/// resolution, with an optional default body (a module declared at this node).
|
/// resolution, with an optional default body (a module declared at this node).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -365,6 +383,9 @@ pub struct Plan {
|
|||||||
/// (keyed by the resolved route, detail noting the source template).
|
/// (keyed by the resolved route, detail noting the source template).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub route_templates: Vec<ResourceChange>,
|
pub route_templates: Vec<ResourceChange>,
|
||||||
|
/// Trigger-template changes on a GROUP node (§4.5, M4b).
|
||||||
|
#[serde(default)]
|
||||||
|
pub trigger_templates: Vec<ResourceChange>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Plan {
|
impl Plan {
|
||||||
@@ -379,6 +400,7 @@ impl Plan {
|
|||||||
.chain(&self.vars)
|
.chain(&self.vars)
|
||||||
.chain(&self.extension_points)
|
.chain(&self.extension_points)
|
||||||
.chain(&self.route_templates)
|
.chain(&self.route_templates)
|
||||||
|
.chain(&self.trigger_templates)
|
||||||
.all(|c| c.op == Op::NoOp)
|
.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.
|
/// 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).
|
/// Empty for an app owner (apps don't own templates).
|
||||||
pub route_templates: Vec<crate::template_repo::RouteTemplate>,
|
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(),
|
.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_route_templates(&bundle.route_templates)?;
|
||||||
|
validate_trigger_templates(&bundle.trigger_templates)?;
|
||||||
}
|
}
|
||||||
ApplyOwner::App(_) => {
|
ApplyOwner::App(_) => {
|
||||||
if !bundle.route_templates.is_empty() {
|
if !bundle.route_templates.is_empty() || !bundle.trigger_templates.is_empty() {
|
||||||
return Err(ApplyError::Invalid(
|
return Err(ApplyError::Invalid(
|
||||||
"an app manifest cannot declare route_templates — templates live on a \
|
"an app manifest cannot declare route_templates/trigger_templates — \
|
||||||
group and fan out to its descendant apps"
|
templates live on a group and fan out to its descendant apps"
|
||||||
.into(),
|
.into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -847,29 +872,9 @@ impl ApplyService {
|
|||||||
let app_id = owner.app_id().expect("triggers only exist on app nodes");
|
let app_id = owner.app_id().expect("triggers only exist on app nodes");
|
||||||
let bt = bundle_triggers[&ch.key];
|
let bt = bundle_triggers[&ch.key];
|
||||||
let sid = resolve_script(&name_to_id, bt.script())?;
|
let sid = resolve_script(&name_to_id, bt.script())?;
|
||||||
if let BundleTrigger::Email {
|
// Hand-declared trigger — no template stamp.
|
||||||
inbound_secret_ref, ..
|
self.insert_bundle_trigger_tx(&mut *tx, app_id, sid, actor, bt, None)
|
||||||
} = bt
|
.await?;
|
||||||
{
|
|
||||||
// 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)?;
|
|
||||||
}
|
|
||||||
report.triggers_created += 1;
|
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
|
// 4. Prune (only with --prune): delete stale triggers, then scripts
|
||||||
// (route removals already happened in the route delete-pass above).
|
// (route removals already happened in the route delete-pass above).
|
||||||
// Secret pruning is deliberately deferred (destructive + irreversible):
|
// Secret pruning is deliberately deferred (destructive + irreversible):
|
||||||
@@ -1047,9 +1084,9 @@ impl ApplyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route templates dropped from a group manifest are deleted (their
|
// Route + trigger templates dropped from a group manifest are
|
||||||
// expanded routes are reaped by the expansion engine, which sees the
|
// deleted (their expansions are reaped by the expansion engine,
|
||||||
// template gone). Group-only, mirroring the reconcile guard above.
|
// which sees the template gone). Group-only.
|
||||||
if let ApplyOwner::Group(gid) = owner {
|
if let ApplyOwner::Group(gid) = owner {
|
||||||
for ch in &plan.route_templates {
|
for ch in &plan.route_templates {
|
||||||
if ch.op == Op::Delete {
|
if ch.op == Op::Delete {
|
||||||
@@ -1059,6 +1096,14 @@ impl ApplyService {
|
|||||||
report.route_templates_deleted += 1;
|
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)
|
Ok(name_to_id)
|
||||||
@@ -1439,6 +1484,20 @@ impl ApplyService {
|
|||||||
&mut report,
|
&mut report,
|
||||||
)
|
)
|
||||||
.await?;
|
.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
|
// apply. A deleted template's expansions in descendant apps NOT in this
|
||||||
// tree survive until those apps are re-applied — warn so the operator
|
// tree survive until those apps are re-applied — warn so the operator
|
||||||
// isn't surprised that a pruned template left live routes elsewhere.
|
// 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(
|
report.warnings.push(
|
||||||
"deleted route template(s): expanded routes in descendant apps not included in \
|
"deleted template(s): expansions in descendant apps not included in this apply \
|
||||||
this apply remain until those apps are re-applied (`pic apply --dir` over them)"
|
remain until those apps are re-applied (`pic apply --dir` over them)"
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1903,7 +1962,9 @@ impl ApplyService {
|
|||||||
// how many app nodes in this apply sit under it (will get expansions).
|
// how many app nodes in this apply sit under it (will get expansions).
|
||||||
let mut template_blast_radius: Vec<TemplateBlastRadius> = Vec::new();
|
let mut template_blast_radius: Vec<TemplateBlastRadius> = Vec::new();
|
||||||
for n in &bundle.nodes {
|
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() {
|
if let Some(gid) = group_id_by_slug.get(&n.slug.to_lowercase()).copied() {
|
||||||
let affected_apps = prepared
|
let affected_apps = prepared
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2000,6 +2061,50 @@ impl ApplyService {
|
|||||||
Ok(out)
|
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
|
/// 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
|
/// app's own (just-reconciled) scripts first, then in-tx group scripts along
|
||||||
/// the chain (nearest-first), then committed inherited scripts (pool). This
|
/// the chain (nearest-first), then committed inherited scripts (pool). This
|
||||||
@@ -2029,7 +2134,7 @@ impl ApplyService {
|
|||||||
{
|
{
|
||||||
Some(s) => Ok(s.id),
|
Some(s) => Ok(s.id),
|
||||||
None => Err(ApplyError::Invalid(format!(
|
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"
|
ancestor group"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
@@ -2241,6 +2346,171 @@ impl ApplyService {
|
|||||||
Ok(rows.into_iter().map(|r| (r.identity(), r)).collect())
|
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> {
|
async fn resolve_app(&self, ident: &str) -> Result<picloud_shared::App, ApplyError> {
|
||||||
crate::app_repo::resolve_app(self.apps.as_ref(), ident)
|
crate::app_repo::resolve_app(self.apps.as_ref(), ident)
|
||||||
.await
|
.await
|
||||||
@@ -2750,14 +3020,17 @@ impl ApplyService {
|
|||||||
.map(|r| (r.key, r.value))
|
.map(|r| (r.key, r.value))
|
||||||
.collect();
|
.collect();
|
||||||
let extension_points = self.load_extension_points(owner).await?;
|
let extension_points = self.load_extension_points(owner).await?;
|
||||||
// Route templates are group-owned only (§4.5, M4a).
|
// Route + trigger templates are group-owned only (§4.5, M4a/M4b).
|
||||||
let route_templates = match owner {
|
let (route_templates, trigger_templates) = match owner {
|
||||||
ApplyOwner::Group(group_id) => {
|
ApplyOwner::Group(group_id) => (
|
||||||
crate::template_repo::list_for_group(&self.pool, group_id)
|
crate::template_repo::list_for_group(&self.pool, group_id)
|
||||||
.await
|
.await
|
||||||
.map_err(map_group_err)?
|
.map_err(map_group_err)?,
|
||||||
}
|
crate::template_repo::list_triggers_for_group(&self.pool, group_id)
|
||||||
ApplyOwner::App(_) => Vec::new(),
|
.await
|
||||||
|
.map_err(map_group_err)?,
|
||||||
|
),
|
||||||
|
ApplyOwner::App(_) => (Vec::new(), Vec::new()),
|
||||||
};
|
};
|
||||||
Ok(CurrentState {
|
Ok(CurrentState {
|
||||||
scripts,
|
scripts,
|
||||||
@@ -2767,6 +3040,7 @@ impl ApplyService {
|
|||||||
vars,
|
vars,
|
||||||
extension_points,
|
extension_points,
|
||||||
route_templates,
|
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
|
// 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.
|
// own no templates); the per-app expansions are appended in prepare_tree.
|
||||||
route_templates: diff_route_templates(current, bundle),
|
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:
|
/// 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
|
/// a changed field is an `Update`, a live template absent from the manifest a
|
||||||
/// `Delete` (applied under `--prune`). Mirrors `diff_extension_points`.
|
/// `Delete` (applied under `--prune`). Mirrors `diff_extension_points`.
|
||||||
@@ -2987,6 +3308,81 @@ fn validate_route_templates(templates: &[RouteTemplateSpec]) -> Result<(), Apply
|
|||||||
Ok(())
|
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
|
/// 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).
|
/// `{` is a hard error (so a typo can't silently pass through to a live route).
|
||||||
fn scan_placeholders(
|
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
|
/// The route-identity keys of a bundle's HAND-declared routes — the set a
|
||||||
/// template expansion is collision-checked against (§4.5, M4a).
|
/// template expansion is collision-checked against (§4.5, M4a).
|
||||||
fn bundle_route_keys(bundle: &Bundle) -> HashSet<String> {
|
fn bundle_route_keys(bundle: &Bundle) -> HashSet<String> {
|
||||||
@@ -4065,6 +4471,14 @@ pub struct ApplyReport {
|
|||||||
pub route_templates_updated: u32,
|
pub route_templates_updated: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub route_templates_deleted: u32,
|
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")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -4395,6 +4809,14 @@ fn state_token_with_names(
|
|||||||
t.enabled,
|
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.
|
// Order-independent: sort the per-resource tokens before hashing.
|
||||||
parts.sort_unstable();
|
parts.sort_unstable();
|
||||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
@@ -4477,6 +4899,7 @@ mod tests {
|
|||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
extension_points: Vec::new(),
|
extension_points: Vec::new(),
|
||||||
route_templates: Vec::new(),
|
route_templates: Vec::new(),
|
||||||
|
trigger_templates: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert_eq!(plan.scripts.len(), 1);
|
assert_eq!(plan.scripts.len(), 1);
|
||||||
@@ -4501,6 +4924,7 @@ mod tests {
|
|||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
extension_points: Vec::new(),
|
extension_points: Vec::new(),
|
||||||
route_templates: Vec::new(),
|
route_templates: Vec::new(),
|
||||||
|
trigger_templates: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
|
assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
|
||||||
@@ -4520,6 +4944,7 @@ mod tests {
|
|||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
extension_points: Vec::new(),
|
extension_points: Vec::new(),
|
||||||
route_templates: Vec::new(),
|
route_templates: Vec::new(),
|
||||||
|
trigger_templates: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert_eq!(plan.scripts[0].op, Op::Update);
|
assert_eq!(plan.scripts[0].op, Op::Update);
|
||||||
@@ -4540,6 +4965,7 @@ mod tests {
|
|||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
extension_points: Vec::new(),
|
extension_points: Vec::new(),
|
||||||
route_templates: Vec::new(),
|
route_templates: Vec::new(),
|
||||||
|
trigger_templates: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert_eq!(plan.scripts[0].op, Op::Delete);
|
assert_eq!(plan.scripts[0].op, Op::Delete);
|
||||||
@@ -4588,6 +5014,7 @@ mod tests {
|
|||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
extension_points: Vec::new(),
|
extension_points: Vec::new(),
|
||||||
route_templates: Vec::new(),
|
route_templates: Vec::new(),
|
||||||
|
trigger_templates: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert_eq!(plan.routes[0].op, Op::Update);
|
assert_eq!(plan.routes[0].op, Op::Update);
|
||||||
@@ -4602,6 +5029,7 @@ mod tests {
|
|||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
extension_points: Vec::new(),
|
extension_points: Vec::new(),
|
||||||
route_templates: 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());
|
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]
|
#[test]
|
||||||
fn validate_route_templates_catches_dups_and_bad_placeholders() {
|
fn validate_route_templates_catches_dups_and_bad_placeholders() {
|
||||||
let mk = |name: &str, path: &str| RouteTemplateSpec {
|
let mk = |name: &str, path: &str| RouteTemplateSpec {
|
||||||
|
|||||||
@@ -198,3 +198,121 @@ pub(crate) async fn delete_tx(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
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_backoff: BackoffShape,
|
||||||
retry_base_ms: u32,
|
retry_base_ms: u32,
|
||||||
details: &TriggerDetails,
|
details: &TriggerDetails,
|
||||||
|
from_template: Option<Uuid>,
|
||||||
) -> Result<TriggerId, TriggerRepoError> {
|
) -> Result<TriggerId, TriggerRepoError> {
|
||||||
let kind = match details {
|
let kind = match details {
|
||||||
TriggerDetails::Kv { .. } => "kv",
|
TriggerDetails::Kv { .. } => "kv",
|
||||||
@@ -562,8 +563,8 @@ pub(crate) async fn insert_trigger_tx(
|
|||||||
"INSERT INTO triggers ( \
|
"INSERT INTO triggers ( \
|
||||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||||
registered_by_principal \
|
registered_by_principal, from_template \
|
||||||
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8) RETURNING id",
|
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, $9) RETURNING id",
|
||||||
)
|
)
|
||||||
.bind(app_id.into_inner())
|
.bind(app_id.into_inner())
|
||||||
.bind(script_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(retry_backoff.as_str())
|
||||||
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
|
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
|
||||||
.bind(registered_by.into_inner())
|
.bind(registered_by.into_inner())
|
||||||
|
.bind(from_template)
|
||||||
.fetch_one(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
let tid = row.0;
|
let tid = row.0;
|
||||||
@@ -679,17 +681,19 @@ pub(crate) async fn insert_email_trigger_tx(
|
|||||||
registered_by: AdminUserId,
|
registered_by: AdminUserId,
|
||||||
inbound_secret_encrypted: &[u8],
|
inbound_secret_encrypted: &[u8],
|
||||||
inbound_secret_nonce: &[u8],
|
inbound_secret_nonce: &[u8],
|
||||||
|
from_template: Option<Uuid>,
|
||||||
) -> Result<TriggerId, TriggerRepoError> {
|
) -> Result<TriggerId, TriggerRepoError> {
|
||||||
let row: (Uuid,) = sqlx::query_as(
|
let row: (Uuid,) = sqlx::query_as(
|
||||||
"INSERT INTO triggers ( \
|
"INSERT INTO triggers ( \
|
||||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||||
registered_by_principal \
|
registered_by_principal, from_template \
|
||||||
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id",
|
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
|
||||||
)
|
)
|
||||||
.bind(app_id.into_inner())
|
.bind(app_id.into_inner())
|
||||||
.bind(script_id.into_inner())
|
.bind(script_id.into_inner())
|
||||||
.bind(registered_by.into_inner())
|
.bind(registered_by.into_inner())
|
||||||
|
.bind(from_template)
|
||||||
.fetch_one(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
|||||||
@@ -370,6 +370,14 @@ table: topics
|
|||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
updated_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
|
table: triggers
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
app_id: uuid NOT NULL
|
app_id: uuid NOT NULL
|
||||||
@@ -384,6 +392,7 @@ table: triggers
|
|||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
updated_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
|
name: text NOT NULL default=(gen_random_uuid())::text
|
||||||
|
from_template: uuid NULL
|
||||||
|
|
||||||
table: vars
|
table: vars
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
@@ -582,11 +591,16 @@ indexes on secrets:
|
|||||||
indexes on topics:
|
indexes on topics:
|
||||||
topics_pkey: public.topics USING btree (app_id, name)
|
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:
|
indexes on triggers:
|
||||||
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
|
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_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)
|
idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true)
|
||||||
triggers_app_name_uniq: public.triggers USING btree (app_id, name)
|
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)
|
triggers_pkey: public.triggers USING btree (id)
|
||||||
|
|
||||||
indexes on vars:
|
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
|
[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)
|
[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:
|
constraints on triggers:
|
||||||
[CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
[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])))
|
[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
|
0051: extension points
|
||||||
0052: owner project
|
0052: owner project
|
||||||
0053: templates
|
0053: templates
|
||||||
|
0054: trigger templates
|
||||||
|
|||||||
@@ -1426,6 +1426,8 @@ pub struct NodePlanDto {
|
|||||||
pub extension_points: Vec<ChangeDto>,
|
pub extension_points: Vec<ChangeDto>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub route_templates: Vec<ChangeDto>,
|
pub route_templates: Vec<ChangeDto>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub trigger_templates: Vec<ChangeDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response of `POST .../apply`: counts of what changed.
|
/// Response of `POST .../apply`: counts of what changed.
|
||||||
@@ -1476,6 +1478,12 @@ pub struct ApplyReportDto {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub route_templates_deleted: u32,
|
pub route_templates_deleted: u32,
|
||||||
#[serde(default)]
|
#[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>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
for w in &report.warnings {
|
||||||
block.field("warning", w.clone());
|
block.field("warning", w.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
|||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
extension_points: Vec::new(),
|
extension_points: Vec::new(),
|
||||||
route_templates: Vec::new(),
|
route_templates: Vec::new(),
|
||||||
|
trigger_templates: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
|||||||
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
||||||
for n in &plan.nodes {
|
for n in &plan.nodes {
|
||||||
let node = format!("{}:{}", n.kind, n.slug);
|
let node = format!("{}:{}", n.kind, n.slug);
|
||||||
let groups: [(&str, &Vec<ChangeDto>); 7] = [
|
let groups: [(&str, &Vec<ChangeDto>); 8] = [
|
||||||
("script", &n.scripts),
|
("script", &n.scripts),
|
||||||
("route", &n.routes),
|
("route", &n.routes),
|
||||||
("trigger", &n.triggers),
|
("trigger", &n.triggers),
|
||||||
@@ -75,6 +75,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
|||||||
("var", &n.vars),
|
("var", &n.vars),
|
||||||
("extension-point", &n.extension_points),
|
("extension-point", &n.extension_points),
|
||||||
("route-template", &n.route_templates),
|
("route-template", &n.route_templates),
|
||||||
|
("trigger-template", &n.trigger_templates),
|
||||||
];
|
];
|
||||||
for (rk, changes) in groups {
|
for (rk, changes) in groups {
|
||||||
for c in changes {
|
for c in changes {
|
||||||
@@ -206,6 +207,15 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
|||||||
.map(serde_json::to_value)
|
.map(serde_json::to_value)
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.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!({
|
Ok(json!({
|
||||||
"scripts": scripts,
|
"scripts": scripts,
|
||||||
"routes": routes,
|
"routes": routes,
|
||||||
@@ -214,6 +224,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
|||||||
"vars": vars,
|
"vars": vars,
|
||||||
"extension_points": extension_points,
|
"extension_points": extension_points,
|
||||||
"route_templates": route_templates,
|
"route_templates": route_templates,
|
||||||
|
"trigger_templates": trigger_templates,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -237,8 +237,9 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
|||||||
},
|
},
|
||||||
vars: manifest_vars,
|
vars: manifest_vars,
|
||||||
extension_points,
|
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(),
|
route_templates: Vec::new(),
|
||||||
|
trigger_templates: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ pub struct Manifest {
|
|||||||
/// fields may carry `{app_slug}`/`{env}`/`{var:NAME}` placeholders.
|
/// fields may carry `{app_slug}`/`{env}`/`{var:NAME}` placeholders.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub route_templates: Vec<ManifestRouteTemplate>,
|
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 {
|
impl Manifest {
|
||||||
@@ -306,6 +312,17 @@ pub struct ManifestRouteTemplate {
|
|||||||
pub enabled: bool,
|
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]]`, …).
|
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct ManifestTriggers {
|
pub struct ManifestTriggers {
|
||||||
@@ -543,6 +560,7 @@ mod tests {
|
|||||||
default: Some("default-theme".into()),
|
default: Some("default-theme".into()),
|
||||||
}],
|
}],
|
||||||
route_templates: Vec::new(),
|
route_templates: Vec::new(),
|
||||||
|
trigger_templates: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,6 +666,7 @@ mod tests {
|
|||||||
vars: BTreeMap::new(),
|
vars: BTreeMap::new(),
|
||||||
extension_points: Vec::new(),
|
extension_points: Vec::new(),
|
||||||
route_templates: Vec::new(),
|
route_templates: Vec::new(),
|
||||||
|
trigger_templates: Vec::new(),
|
||||||
};
|
};
|
||||||
let text = m.to_toml().unwrap();
|
let text = m.to_toml().unwrap();
|
||||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||||
|
|||||||
@@ -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 {
|
fn group_script_id(env: &common::TestEnv, group: &str) -> String {
|
||||||
let ls = common::pic_as(env)
|
let ls = common::pic_as(env)
|
||||||
.args(["scripts", "ls", "--group", group])
|
.args(["scripts", "ls", "--group", group])
|
||||||
|
|||||||
@@ -379,27 +379,35 @@ Two distinct constraints:
|
|||||||
> needing the same declaration = 100× the declarations. The fix is group trigger/route **templates**
|
> needing the same declaration = 100× the declarations. The fix is group trigger/route **templates**
|
||||||
> that fan out per descendant (a *template/instantiation* mechanism, not inheritance).
|
> that fan out per descendant (a *template/instantiation* mechanism, not inheritance).
|
||||||
>
|
>
|
||||||
> **Status: ROUTE templates ✅ shipped (M4a, 2026-06-28).** Migration `0053_templates.sql` adds a
|
> **Status: ✅ shipped — routes (M4a) AND triggers (M4b), 2026-06-28.** Migrations `0053_templates.sql`
|
||||||
> `route_templates` table (owned by a group) and a `routes.from_template` provenance column. A
|
> (`route_templates` + `routes.from_template`) and `0054_trigger_templates.sql` (`trigger_templates` +
|
||||||
> `[group]` manifest declares `[[route_templates]]`; `pic apply --dir` reconciles the template rows
|
> `triggers.from_template`). A `[group]` manifest declares `[[route_templates]]` and/or
|
||||||
|
> `[[trigger_templates]]` (the latter a flat block: `name`, `kind`, `script`, + kind params, covering
|
||||||
|
> all seven kinds); `pic apply --dir` reconciles the template rows
|
||||||
> (group-only — an app declaring one is a 422) and, in tree Phase B, **fans each template out into one
|
> (group-only — an app declaring one is a 422) and, in tree Phase B, **fans each template out into one
|
||||||
> concrete `routes` row per descendant app node** in the apply, resolving the script binding
|
> concrete `routes`/`triggers` row per descendant app node** in the apply, resolving the script binding
|
||||||
> nearest-owner-wins and substituting an inert placeholder set — `{app_slug}`, `{env}` (from the
|
> nearest-owner-wins and substituting an inert placeholder set — `{app_slug}`, `{env}` (from the
|
||||||
> apply's selected env), `{var:NAME}` (the app's effective var; an unknown var/placeholder is a hard
|
> apply's selected env), `{var:NAME}` (the app's effective var; an unknown var/placeholder is a hard
|
||||||
> error). Expansion is **idempotent via `from_template`** (diffed create/update-as-replace/delete, so
|
> error). For triggers the placeholders resolve in every string leaf of the spec, then the typed
|
||||||
> re-apply doesn't churn and a removed/disabled template reaps its routes under `--prune`); an
|
> trigger is rebuilt and inserted through the normal path (email secrets are resolved + re-sealed per
|
||||||
> expansion colliding with a hand-declared route, or two templates colliding, is a hard error. `plan`
|
> app — the value never travels in a manifest). Expansion is **idempotent via `from_template`** (routes
|
||||||
> reports the **blast radius** (descendant app count) and the `state_token` folds each template, so an
|
> diff by identity create/update-as-replace/delete; triggers key one-per-template-per-app and compare
|
||||||
> edit between plan and apply trips `StateMoved`. Authz: declaring a template needs `GroupScriptsWrite`;
|
> semantic identity, so re-apply doesn't churn and a removed/disabled template reaps its expansions
|
||||||
> an app that *receives* expansions needs `AppWriteRoute` (gated even when the app declares no routes
|
> under `--prune`); an expansion colliding with a hand-declared route/trigger, or two templates
|
||||||
> of its own). Each RESOLVED expansion is validated with the same rules a hand-declared route gets —
|
> colliding, is a hard error. `plan` reports the **blast radius** (descendant app count) and the
|
||||||
> reserved-path rejection, structural path/host parse, and host-claim check — so a `{var:…}`-injected
|
> `state_token` folds each template, so an edit between plan and apply trips `StateMoved`. Authz:
|
||||||
> reserved path or an unclaimed strict host can't slip in. **Scope:** expansion targets app NODES
|
> declaring a template needs `GroupScriptsWrite`; an app that *receives* expansions needs
|
||||||
> present in the tree apply (the common `pic apply --dir` case), not yet every descendant in another
|
> `AppWriteRoute` / `AppManageTriggers` (and `AppSecretsRead` for an email-trigger template, which
|
||||||
> repo — deleting a template leaves expansions in out-of-apply descendants until they're re-applied
|
> seals the recipient's secret) — gated even when the app declares none of its own. Each RESOLVED
|
||||||
> (the apply warns); `{var:NAME}` resolves against *committed* vars (a var set in the same apply lands
|
> expansion is validated with the same rules its hand-declared counterpart gets — routes:
|
||||||
> next apply). **Trigger templates (M4b) remain** — they reuse this engine over the `triggers` insert
|
> reserved-path rejection + structural path/host parse + host-claim; triggers: the per-kind shape
|
||||||
> path (the seven kinds + email-secret sealing).
|
> check (cron schedule, queue timeout, …) — so a `{var:…}`-injected reserved path, unclaimed strict
|
||||||
|
> host, or malformed schedule/timeout can't slip in. **Scope:** expansion targets app NODES present in the tree apply
|
||||||
|
> (the common `pic apply --dir` case), not yet every descendant in another repo — deleting a template
|
||||||
|
> leaves expansions in out-of-apply descendants until they're re-applied (the apply warns);
|
||||||
|
> `{var:NAME}` resolves against *committed* vars (a var set in the same apply lands next apply). A
|
||||||
|
> trigger template whose only change is a non-identity field (e.g. dispatch_mode) is a no-op on
|
||||||
|
> re-apply, mirroring the existing trigger-identity limitation (recreate to change those).
|
||||||
|
|
||||||
### 4.6 Secrets & `pull`
|
### 4.6 Secrets & `pull`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user