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) => {
|
||||
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(
|
||||
|
||||
@@ -370,6 +370,14 @@ table: topics
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: trigger_templates
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NOT NULL
|
||||
name: text NOT NULL
|
||||
spec: jsonb NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: triggers
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
app_id: uuid NOT NULL
|
||||
@@ -384,6 +392,7 @@ table: triggers
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
name: text NOT NULL default=(gen_random_uuid())::text
|
||||
from_template: uuid NULL
|
||||
|
||||
table: vars
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
@@ -582,11 +591,16 @@ indexes on secrets:
|
||||
indexes on topics:
|
||||
topics_pkey: public.topics USING btree (app_id, name)
|
||||
|
||||
indexes on trigger_templates:
|
||||
trigger_templates_group_name_idx: public.trigger_templates USING btree (group_id, lower(name))
|
||||
trigger_templates_pkey: public.trigger_templates USING btree (id)
|
||||
|
||||
indexes on triggers:
|
||||
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
|
||||
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
|
||||
idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true)
|
||||
triggers_app_name_uniq: public.triggers USING btree (app_id, name)
|
||||
triggers_from_template_idx: public.triggers USING btree (app_id, from_template)
|
||||
triggers_pkey: public.triggers USING btree (id)
|
||||
|
||||
indexes on vars:
|
||||
@@ -802,6 +816,10 @@ constraints on topics:
|
||||
[FOREIGN KEY] topics_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] topics_pkey: PRIMARY KEY (app_id, name)
|
||||
|
||||
constraints on trigger_templates:
|
||||
[FOREIGN KEY] trigger_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] trigger_templates_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on triggers:
|
||||
[CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
||||
[CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text, 'queue'::text])))
|
||||
@@ -871,3 +889,4 @@ constraints on vars:
|
||||
0051: extension points
|
||||
0052: owner project
|
||||
0053: templates
|
||||
0054: trigger templates
|
||||
|
||||
@@ -1426,6 +1426,8 @@ pub struct NodePlanDto {
|
||||
pub extension_points: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub route_templates: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub trigger_templates: Vec<ChangeDto>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../apply`: counts of what changed.
|
||||
@@ -1476,6 +1478,12 @@ pub struct ApplyReportDto {
|
||||
#[serde(default)]
|
||||
pub route_templates_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_created: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_updated: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,20 @@ pub async fn run_tree(
|
||||
),
|
||||
);
|
||||
}
|
||||
if report.trigger_templates_created > 0
|
||||
|| report.trigger_templates_updated > 0
|
||||
|| report.trigger_templates_deleted > 0
|
||||
{
|
||||
block.field(
|
||||
"trigger-templates",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.trigger_templates_created,
|
||||
report.trigger_templates_updated,
|
||||
report.trigger_templates_deleted
|
||||
),
|
||||
);
|
||||
}
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
||||
for n in &plan.nodes {
|
||||
let node = format!("{}:{}", n.kind, n.slug);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 7] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 8] = [
|
||||
("script", &n.scripts),
|
||||
("route", &n.routes),
|
||||
("trigger", &n.triggers),
|
||||
@@ -75,6 +75,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
("var", &n.vars),
|
||||
("extension-point", &n.extension_points),
|
||||
("route-template", &n.route_templates),
|
||||
("trigger-template", &n.trigger_templates),
|
||||
];
|
||||
for (rk, changes) in groups {
|
||||
for c in changes {
|
||||
@@ -206,6 +207,15 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// Trigger templates (§4.5, M4b): name + the flattened trigger spec
|
||||
// (kind/script/params) — the server's `TriggerTemplateSpec` deserializes
|
||||
// `{ name, <BundleTrigger> }`. Group manifests only.
|
||||
let trigger_templates = manifest
|
||||
.trigger_templates
|
||||
.iter()
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(json!({
|
||||
"scripts": scripts,
|
||||
"routes": routes,
|
||||
@@ -214,6 +224,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
"vars": vars,
|
||||
"extension_points": extension_points,
|
||||
"route_templates": route_templates,
|
||||
"trigger_templates": trigger_templates,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -237,8 +237,9 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
},
|
||||
vars: manifest_vars,
|
||||
extension_points,
|
||||
// `pull` is app-scoped; route templates are group-owned (§4.5, M4a).
|
||||
// `pull` is app-scoped; templates are group-owned (§4.5).
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
|
||||
@@ -58,6 +58,12 @@ pub struct Manifest {
|
||||
/// fields may carry `{app_slug}`/`{env}`/`{var:NAME}` placeholders.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub route_templates: Vec<ManifestRouteTemplate>,
|
||||
/// `[[trigger_templates]]` — GROUP-only trigger declarations that fan out per
|
||||
/// descendant app (§4.5, M4b). Each entry is `name = …`, `kind = "cron"|…`,
|
||||
/// `script = …`, plus the kind's params (string fields may carry
|
||||
/// placeholders). Kept loosely-typed so one block covers all seven kinds.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub trigger_templates: Vec<ManifestTriggerTemplate>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
@@ -306,6 +312,17 @@ pub struct ManifestRouteTemplate {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// `[[trigger_templates]]` — a trigger declared once on a group, fanned out per
|
||||
/// descendant app (§4.5, M4b). `name` is the per-group identity; the remaining
|
||||
/// keys (`kind`, `script`, kind params) are kept as a flattened table so one
|
||||
/// block covers every kind, matching the server's `{ name, <BundleTrigger> }`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestTriggerTemplate {
|
||||
pub name: String,
|
||||
#[serde(flatten)]
|
||||
pub spec: toml::Value,
|
||||
}
|
||||
|
||||
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestTriggers {
|
||||
@@ -543,6 +560,7 @@ mod tests {
|
||||
default: Some("default-theme".into()),
|
||||
}],
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,6 +666,7 @@ mod tests {
|
||||
vars: BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
};
|
||||
let text = m.to_toml().unwrap();
|
||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||
|
||||
@@ -208,6 +208,119 @@ fn expansion_colliding_with_hand_declared_route_is_rejected() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Template-expanded kv triggers for the two apps, as `(collection_glob, id)`,
|
||||
/// read straight from Postgres (a group-script trigger isn't listable via the
|
||||
/// app trigger API). Scoped to the test's two slugs.
|
||||
fn trigger_rows(a: &str, b: &str) -> Vec<(String, String)> {
|
||||
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
||||
let want = [format!("{a}-data"), format!("{b}-data")];
|
||||
let rows = pg
|
||||
.query(
|
||||
"SELECT d.collection_glob, t.id::text FROM triggers t \
|
||||
JOIN kv_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.from_template IS NOT NULL AND d.collection_glob = ANY($1) \
|
||||
ORDER BY d.collection_glob",
|
||||
&[&&want[..]],
|
||||
)
|
||||
.expect("query trigger expansions");
|
||||
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
|
||||
}
|
||||
|
||||
const TRIGGER_TEMPLATE: &str = "[[trigger_templates]]\nname = \"ev\"\nkind = \"kv\"\n\
|
||||
script = \"shared\"\ncollection_glob = \"{app_slug}-data\"\n\
|
||||
ops = [\"insert\"]\n";
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn trigger_template_fans_out_per_app_idempotently_then_prunes() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("ttpl-g");
|
||||
let a = common::unique_slug("ttpl-a");
|
||||
let b = common::unique_slug("ttpl-b");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _aa = AppGuard::new(&env.url, &env.token, &a);
|
||||
let _ab = AppGuard::new(&env.url, &env.token, &b);
|
||||
for slug in [&a, &b] {
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", slug, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
// group manifest: a `shared` script + a kv trigger template using {app_slug}.
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/shared.rhai"), r#""x""#).unwrap();
|
||||
let write_group = |with_tmpl: bool| {
|
||||
let tmpl = if with_tmpl { TRIGGER_TEMPLATE } else { "" };
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"TT\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{tmpl}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
write_group(true);
|
||||
for slug in [&a, &b] {
|
||||
fs::create_dir_all(dir.path().join(slug)).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(slug).join("picloud.toml"),
|
||||
format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
|
||||
|
||||
let first = trigger_rows(&a, &b);
|
||||
assert_eq!(
|
||||
first.iter().map(|(g, _)| g.as_str()).collect::<Vec<_>>(),
|
||||
vec![format!("{a}-data").as_str(), format!("{b}-data").as_str()],
|
||||
"each app gets its own {{app_slug}}-resolved kv trigger"
|
||||
);
|
||||
|
||||
// Idempotent re-apply: same rows, same ids.
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
trigger_rows(&a, &b),
|
||||
first,
|
||||
"re-apply must not churn triggers"
|
||||
);
|
||||
|
||||
// Remove the template + --prune: trigger expansions reaped.
|
||||
write_group(false);
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.args(["--prune", "--yes"])
|
||||
.assert()
|
||||
.success();
|
||||
assert!(
|
||||
trigger_rows(&a, &b).is_empty(),
|
||||
"removing the trigger template must reap its expansions"
|
||||
);
|
||||
}
|
||||
|
||||
fn group_script_id(env: &common::TestEnv, group: &str) -> String {
|
||||
let ls = common::pic_as(env)
|
||||
.args(["scripts", "ls", "--group", group])
|
||||
|
||||
@@ -379,27 +379,35 @@ Two distinct constraints:
|
||||
> needing the same declaration = 100× the declarations. The fix is group trigger/route **templates**
|
||||
> that fan out per descendant (a *template/instantiation* mechanism, not inheritance).
|
||||
>
|
||||
> **Status: ROUTE templates ✅ shipped (M4a, 2026-06-28).** Migration `0053_templates.sql` adds a
|
||||
> `route_templates` table (owned by a group) and a `routes.from_template` provenance column. A
|
||||
> `[group]` manifest declares `[[route_templates]]`; `pic apply --dir` reconciles the template rows
|
||||
> **Status: ✅ shipped — routes (M4a) AND triggers (M4b), 2026-06-28.** Migrations `0053_templates.sql`
|
||||
> (`route_templates` + `routes.from_template`) and `0054_trigger_templates.sql` (`trigger_templates` +
|
||||
> `triggers.from_template`). A `[group]` manifest declares `[[route_templates]]` and/or
|
||||
> `[[trigger_templates]]` (the latter a flat block: `name`, `kind`, `script`, + kind params, covering
|
||||
> all seven kinds); `pic apply --dir` reconciles the template rows
|
||||
> (group-only — an app declaring one is a 422) and, in tree Phase B, **fans each template out into one
|
||||
> concrete `routes` row per descendant app node** in the apply, resolving the script binding
|
||||
> concrete `routes`/`triggers` row per descendant app node** in the apply, resolving the script binding
|
||||
> nearest-owner-wins and substituting an inert placeholder set — `{app_slug}`, `{env}` (from the
|
||||
> apply's selected env), `{var:NAME}` (the app's effective var; an unknown var/placeholder is a hard
|
||||
> error). Expansion is **idempotent via `from_template`** (diffed create/update-as-replace/delete, so
|
||||
> re-apply doesn't churn and a removed/disabled template reaps its routes under `--prune`); an
|
||||
> expansion colliding with a hand-declared route, or two templates colliding, is a hard error. `plan`
|
||||
> reports the **blast radius** (descendant app count) and the `state_token` folds each template, so an
|
||||
> edit between plan and apply trips `StateMoved`. Authz: declaring a template needs `GroupScriptsWrite`;
|
||||
> an app that *receives* expansions needs `AppWriteRoute` (gated even when the app declares no routes
|
||||
> of its own). Each RESOLVED expansion is validated with the same rules a hand-declared route gets —
|
||||
> reserved-path rejection, structural path/host parse, and host-claim check — so a `{var:…}`-injected
|
||||
> reserved path or an unclaimed strict host can't slip in. **Scope:** expansion targets app NODES
|
||||
> present in the tree apply (the common `pic apply --dir` case), not yet every descendant in another
|
||||
> repo — deleting a template leaves expansions in out-of-apply descendants until they're re-applied
|
||||
> (the apply warns); `{var:NAME}` resolves against *committed* vars (a var set in the same apply lands
|
||||
> next apply). **Trigger templates (M4b) remain** — they reuse this engine over the `triggers` insert
|
||||
> path (the seven kinds + email-secret sealing).
|
||||
> error). For triggers the placeholders resolve in every string leaf of the spec, then the typed
|
||||
> trigger is rebuilt and inserted through the normal path (email secrets are resolved + re-sealed per
|
||||
> app — the value never travels in a manifest). Expansion is **idempotent via `from_template`** (routes
|
||||
> diff by identity create/update-as-replace/delete; triggers key one-per-template-per-app and compare
|
||||
> semantic identity, so re-apply doesn't churn and a removed/disabled template reaps its expansions
|
||||
> under `--prune`); an expansion colliding with a hand-declared route/trigger, or two templates
|
||||
> colliding, is a hard error. `plan` reports the **blast radius** (descendant app count) and the
|
||||
> `state_token` folds each template, so an edit between plan and apply trips `StateMoved`. Authz:
|
||||
> declaring a template needs `GroupScriptsWrite`; an app that *receives* expansions needs
|
||||
> `AppWriteRoute` / `AppManageTriggers` (and `AppSecretsRead` for an email-trigger template, which
|
||||
> seals the recipient's secret) — gated even when the app declares none of its own. Each RESOLVED
|
||||
> expansion is validated with the same rules its hand-declared counterpart gets — routes:
|
||||
> reserved-path rejection + structural path/host parse + host-claim; triggers: the per-kind shape
|
||||
> check (cron schedule, queue timeout, …) — so a `{var:…}`-injected reserved path, unclaimed strict
|
||||
> host, or malformed schedule/timeout can't slip in. **Scope:** expansion targets app NODES present in the tree apply
|
||||
> (the common `pic apply --dir` case), not yet every descendant in another repo — deleting a template
|
||||
> leaves expansions in out-of-apply descendants until they're re-applied (the apply warns);
|
||||
> `{var:NAME}` resolves against *committed* vars (a var set in the same apply lands next apply). A
|
||||
> trigger template whose only change is a non-identity field (e.g. dispatch_mode) is a no-op on
|
||||
> re-apply, mirroring the existing trigger-identity limitation (recreate to change those).
|
||||
|
||||
### 4.6 Secrets & `pull`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user