feat(sealed): author + persist sealed group templates (§11 tail M2)
Thread `sealed` from the manifest through to the DB column: - manifest: `sealed` on ManifestRoute + the four event-kind trigger specs (Kv/Docs/Files/Pubsub), flowing to Bundle via serde. - persist: NewRoute.sealed + insert_route_tx; insert_trigger_tx `sealed` param; the reconcile passes br.sealed / bt.sealed(). - validate_bundle_for rejects `sealed` on an app owner (meaningless — an app route/trigger is never inherited). - diff: `sealed` joins the route Update comparison and the trigger identity (both bundle + current sides), so toggling it re-applies rather than NoOp. `sealed` lives on shared Route + manager-core Trigger (both pure DTOs) so the diff can see the current value; RouteRow/TriggerRow default it so RETURNING clauses that omit it still hydrate. No runtime read effect yet — the two suppression gates land in M3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,7 @@ async fn seed_into(
|
||||
method: None,
|
||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -159,6 +159,11 @@ pub struct BundleRoute {
|
||||
/// Three-state lifecycle (§4.3); omitted ⇒ active.
|
||||
#[serde(default = "picloud_shared::default_true")]
|
||||
pub enabled: bool,
|
||||
/// §11 tail: `sealed = true` on a group route template makes it
|
||||
/// non-suppressible — a descendant's `[suppress]` cannot decline it.
|
||||
/// Rejected (in `validate_bundle_for`) on an app owner. Omitted ⇒ unsealed.
|
||||
#[serde(default)]
|
||||
pub sealed: bool,
|
||||
}
|
||||
|
||||
/// Desired trigger, tagged by kind on the wire (`{ "kind": "cron", … }`).
|
||||
@@ -174,6 +179,10 @@ pub enum BundleTrigger {
|
||||
dispatch_mode: Option<TriggerDispatchMode>,
|
||||
#[serde(default)]
|
||||
retry_max_attempts: Option<u32>,
|
||||
/// §11 tail: sealed group template (event kinds only). See
|
||||
/// [`BundleTrigger::sealed`].
|
||||
#[serde(default)]
|
||||
sealed: bool,
|
||||
},
|
||||
Docs {
|
||||
script: String,
|
||||
@@ -184,6 +193,8 @@ pub enum BundleTrigger {
|
||||
dispatch_mode: Option<TriggerDispatchMode>,
|
||||
#[serde(default)]
|
||||
retry_max_attempts: Option<u32>,
|
||||
#[serde(default)]
|
||||
sealed: bool,
|
||||
},
|
||||
Files {
|
||||
script: String,
|
||||
@@ -194,6 +205,8 @@ pub enum BundleTrigger {
|
||||
dispatch_mode: Option<TriggerDispatchMode>,
|
||||
#[serde(default)]
|
||||
retry_max_attempts: Option<u32>,
|
||||
#[serde(default)]
|
||||
sealed: bool,
|
||||
},
|
||||
Cron {
|
||||
script: String,
|
||||
@@ -212,6 +225,8 @@ pub enum BundleTrigger {
|
||||
dispatch_mode: Option<TriggerDispatchMode>,
|
||||
#[serde(default)]
|
||||
retry_max_attempts: Option<u32>,
|
||||
#[serde(default)]
|
||||
sealed: bool,
|
||||
},
|
||||
Email {
|
||||
script: String,
|
||||
@@ -262,35 +277,55 @@ impl BundleTrigger {
|
||||
matches!(self, Self::Email { .. })
|
||||
}
|
||||
|
||||
/// §11 tail: whether this template is `sealed` (non-suppressible). Only the
|
||||
/// event kinds (which can be group templates) carry the flag; the app-only
|
||||
/// kinds (cron/email/queue) are never sealed.
|
||||
#[must_use]
|
||||
pub fn sealed(&self) -> bool {
|
||||
match self {
|
||||
Self::Kv { sealed, .. }
|
||||
| Self::Docs { sealed, .. }
|
||||
| Self::Files { sealed, .. }
|
||||
| Self::Pubsub { sealed, .. } => *sealed,
|
||||
Self::Cron { .. } | Self::Email { .. } | Self::Queue { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable semantic identity (the per-kind tuple), used for the diff.
|
||||
#[must_use]
|
||||
pub fn identity(&self) -> String {
|
||||
match self {
|
||||
// §11 tail: `sealed` is part of the identity for the event kinds, so
|
||||
// toggling it re-diffs (a Create + a Delete-on-prune of the stale
|
||||
// row) like any other definitional change on a template.
|
||||
Self::Kv {
|
||||
script,
|
||||
collection_glob,
|
||||
ops,
|
||||
sealed,
|
||||
..
|
||||
} => format!(
|
||||
"kv|{script}|{collection_glob}|{}",
|
||||
"kv|{script}|{collection_glob}|{}|{sealed}",
|
||||
sorted_csv(ops.iter().copied().map(KvEventOp::as_str))
|
||||
),
|
||||
Self::Docs {
|
||||
script,
|
||||
collection_glob,
|
||||
ops,
|
||||
sealed,
|
||||
..
|
||||
} => format!(
|
||||
"docs|{script}|{collection_glob}|{}",
|
||||
"docs|{script}|{collection_glob}|{}|{sealed}",
|
||||
sorted_csv(ops.iter().copied().map(DocsEventOp::as_str))
|
||||
),
|
||||
Self::Files {
|
||||
script,
|
||||
collection_glob,
|
||||
ops,
|
||||
sealed,
|
||||
..
|
||||
} => format!(
|
||||
"files|{script}|{collection_glob}|{}",
|
||||
"files|{script}|{collection_glob}|{}|{sealed}",
|
||||
sorted_csv(ops.iter().copied().map(FilesEventOp::as_str))
|
||||
),
|
||||
Self::Cron {
|
||||
@@ -302,8 +337,9 @@ impl BundleTrigger {
|
||||
Self::Pubsub {
|
||||
script,
|
||||
topic_pattern,
|
||||
sealed,
|
||||
..
|
||||
} => format!("pubsub|{script}|{topic_pattern}"),
|
||||
} => format!("pubsub|{script}|{topic_pattern}|{sealed}"),
|
||||
Self::Email { script, .. } => format!("email|{script}"),
|
||||
Self::Queue { queue_name, .. } => format!("queue|{queue_name}"),
|
||||
}
|
||||
@@ -705,6 +741,28 @@ impl ApplyService {
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
// §11 tail: `sealed` is a GROUP-template property — it makes an
|
||||
// inherited template non-suppressible. On an app-owned route/trigger it
|
||||
// is meaningless (nothing inherits it), so reject it rather than persist
|
||||
// a misleading `sealed` app row the filters never consult. Defense in
|
||||
// depth over the CLI (which authors `sealed` freely but only a group
|
||||
// node inherits down).
|
||||
if matches!(owner, ApplyOwner::App(_)) {
|
||||
if bundle.routes.iter().any(|r| r.sealed) {
|
||||
return Err(ApplyError::Invalid(
|
||||
"an app route cannot be `sealed` — sealing marks a group \
|
||||
template as non-suppressible; an app route is never inherited"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if bundle.triggers.iter().any(BundleTrigger::sealed) {
|
||||
return Err(ApplyError::Invalid(
|
||||
"an app trigger cannot be `sealed` — sealing marks a group \
|
||||
template as non-suppressible; an app trigger is never inherited"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
// §11 tail: only an APP opts out of inherited templates. A group that
|
||||
// doesn't want a template simply doesn't declare it (the CLI already
|
||||
// rejects `[suppress]` on a `[group]`; this guards a hand-rolled wire
|
||||
@@ -937,6 +995,9 @@ impl ApplyService {
|
||||
retry_max,
|
||||
backoff,
|
||||
base,
|
||||
// §11 tail: a sealed group template is non-suppressible
|
||||
// (rejected on an app owner in `validate_bundle_for`).
|
||||
bt.sealed(),
|
||||
&details,
|
||||
)
|
||||
.await
|
||||
@@ -2884,6 +2945,9 @@ fn diff_routes(
|
||||
|| cur.dispatch_mode != r.dispatch_mode
|
||||
|| cur.host_param_name != r.host_param_name
|
||||
|| cur.enabled != r.enabled
|
||||
// §11 tail: a sealed toggle on a group route template must
|
||||
// re-insert the row so the rebuild sees the new flag.
|
||||
|| cur.sealed != r.sealed
|
||||
{
|
||||
out.push(ResourceChange {
|
||||
op: Op::Update,
|
||||
@@ -3173,33 +3237,36 @@ fn validate_email_secrets_present(
|
||||
/// as NoOp, but `diff_triggers` separately refuses to emit a Delete for it.
|
||||
fn current_trigger_identity(t: &Trigger, name_by_id: &HashMap<ScriptId, String>) -> Option<String> {
|
||||
let script = name_by_id.get(&t.script_id)?;
|
||||
// §11 tail: `sealed` is part of the identity for the event kinds (mirroring
|
||||
// `BundleTrigger::identity`), so toggling it on a group template re-diffs.
|
||||
let sealed = t.sealed;
|
||||
match &t.details {
|
||||
TriggerDetails::Kv {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => Some(format!(
|
||||
"kv|{script}|{collection_glob}|{}",
|
||||
"kv|{script}|{collection_glob}|{}|{sealed}",
|
||||
sorted_csv(ops.iter().copied().map(KvEventOp::as_str))
|
||||
)),
|
||||
TriggerDetails::Docs {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => Some(format!(
|
||||
"docs|{script}|{collection_glob}|{}",
|
||||
"docs|{script}|{collection_glob}|{}|{sealed}",
|
||||
sorted_csv(ops.iter().copied().map(DocsEventOp::as_str))
|
||||
)),
|
||||
TriggerDetails::Files {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => Some(format!(
|
||||
"files|{script}|{collection_glob}|{}",
|
||||
"files|{script}|{collection_glob}|{}|{sealed}",
|
||||
sorted_csv(ops.iter().copied().map(FilesEventOp::as_str))
|
||||
)),
|
||||
TriggerDetails::Cron {
|
||||
schedule, timezone, ..
|
||||
} => Some(format!("cron|{script}|{schedule}|{timezone}")),
|
||||
TriggerDetails::Pubsub { topic_pattern } => {
|
||||
Some(format!("pubsub|{script}|{topic_pattern}"))
|
||||
Some(format!("pubsub|{script}|{topic_pattern}|{sealed}"))
|
||||
}
|
||||
TriggerDetails::Email { .. } => Some(format!("email|{script}")),
|
||||
TriggerDetails::Queue { queue_name, .. } => Some(format!("queue|{queue_name}")),
|
||||
@@ -3498,6 +3565,9 @@ async fn insert_bundle_route(
|
||||
method: br.method.clone(),
|
||||
dispatch_mode: br.dispatch_mode,
|
||||
enabled: br.enabled,
|
||||
// §11 tail: a sealed group route template is non-suppressible. Rejected
|
||||
// on an app owner earlier in `validate_bundle_for`.
|
||||
sealed: br.sealed,
|
||||
};
|
||||
insert_route_tx(tx, &new).await.map_err(map_repo)?;
|
||||
Ok(())
|
||||
@@ -3844,6 +3914,7 @@ mod tests {
|
||||
method: Some("POST".into()),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
let current = CurrentState {
|
||||
@@ -3864,6 +3935,7 @@ mod tests {
|
||||
path: "/p".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
}],
|
||||
triggers: vec![],
|
||||
secrets: vec![],
|
||||
@@ -3910,6 +3982,7 @@ mod tests {
|
||||
name: "t".into(),
|
||||
kind,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
retry_max_attempts: 3,
|
||||
retry_backoff: BackoffShape::Exponential,
|
||||
@@ -3989,6 +4062,7 @@ mod tests {
|
||||
ops: vec![],
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
sealed: false,
|
||||
})
|
||||
.is_err());
|
||||
// A non-empty glob passes.
|
||||
@@ -3998,6 +4072,7 @@ mod tests {
|
||||
ops: vec![],
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
sealed: false,
|
||||
})
|
||||
.is_ok());
|
||||
// A malformed pubsub topic_pattern (mid-pattern wildcard) is rejected;
|
||||
@@ -4007,6 +4082,7 @@ mod tests {
|
||||
topic_pattern: "user.*.created".into(),
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
sealed: false,
|
||||
})
|
||||
.is_err());
|
||||
assert!(validate_trigger_shape(&BundleTrigger::Pubsub {
|
||||
@@ -4014,6 +4090,7 @@ mod tests {
|
||||
topic_pattern: "orders.created".into(),
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
sealed: false,
|
||||
})
|
||||
.is_ok());
|
||||
// Queue visibility floor matches the interactive API (>= 30): a value
|
||||
@@ -4209,6 +4286,7 @@ mod tests {
|
||||
path: "/h".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
}];
|
||||
let w = disabled_target_warnings(&b);
|
||||
assert!(
|
||||
@@ -4241,6 +4319,7 @@ mod tests {
|
||||
path: "/o".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
}];
|
||||
assert!(unreachable_endpoint_warnings(&b).is_empty());
|
||||
// A module is exempt even with no binding.
|
||||
@@ -4311,6 +4390,7 @@ mod tests {
|
||||
ops: vec![KvEventOp::Insert, KvEventOp::Update], // reversed
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
sealed: false,
|
||||
}];
|
||||
let p = compute_diff(¤t, &b);
|
||||
assert!(
|
||||
@@ -4319,6 +4399,96 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sealed_is_part_of_trigger_identity() {
|
||||
// §11 tail: toggling `sealed` on a trigger template is a definitional
|
||||
// change — it must re-diff (Create the sealed row + Delete the old),
|
||||
// not silently NoOp. A current UNSEALED kv trigger vs a desired SEALED
|
||||
// one at the same script/glob/ops.
|
||||
let s = script("h", "x");
|
||||
let sid = s.id;
|
||||
let mut cur = trig(
|
||||
sid,
|
||||
TriggerDetails::Kv {
|
||||
collection_glob: "users".into(),
|
||||
ops: vec![KvEventOp::Insert],
|
||||
},
|
||||
);
|
||||
cur.sealed = false;
|
||||
let current = CurrentState {
|
||||
scripts: vec![s],
|
||||
triggers: vec![cur],
|
||||
..CurrentState::default()
|
||||
};
|
||||
let mut b = empty_bundle();
|
||||
b.triggers = vec![BundleTrigger::Kv {
|
||||
script: "h".into(),
|
||||
collection_glob: "users".into(),
|
||||
ops: vec![KvEventOp::Insert],
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
sealed: true,
|
||||
}];
|
||||
let p = compute_diff(¤t, &b);
|
||||
assert!(
|
||||
p.triggers.iter().any(|c| c.op == Op::Create),
|
||||
"sealing must Create the sealed row: {p:?}"
|
||||
);
|
||||
assert!(
|
||||
p.triggers.iter().any(|c| c.op == Op::Delete),
|
||||
"sealing must Delete the stale unsealed row: {p:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sealed_toggle_updates_route() {
|
||||
// §11 tail: a route template carries `sealed`; toggling it is an Update
|
||||
// (routes have an Update op, unlike triggers). Same binding tuple, only
|
||||
// `sealed` differs.
|
||||
let s = script("h", "x");
|
||||
let sid = s.id;
|
||||
let mut route = Route {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
app_id: None,
|
||||
group_id: Some(GroupId::from(uuid::Uuid::nil())),
|
||||
script_id: sid,
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/h".into(),
|
||||
method: None,
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
route.sealed = false;
|
||||
let current = CurrentState {
|
||||
scripts: vec![s],
|
||||
routes: vec![route],
|
||||
..CurrentState::default()
|
||||
};
|
||||
let mut b = empty_bundle();
|
||||
b.routes = vec![BundleRoute {
|
||||
script: "h".into(),
|
||||
method: None,
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/h".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
sealed: true,
|
||||
}];
|
||||
let p = compute_diff(¤t, &b);
|
||||
assert!(
|
||||
p.routes.iter().any(|c| c.op == Op::Update),
|
||||
"toggling sealed on a route must Update it: {p:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_letter_trigger_is_ignored_by_diff() {
|
||||
// Dead-letter triggers can't be expressed in the manifest, so the
|
||||
|
||||
@@ -241,6 +241,9 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
dispatch_mode: input.dispatch_mode,
|
||||
// Routes are created active; toggling is a dedicated path.
|
||||
enabled: true,
|
||||
// Sealing is a group-template property (§11 tail); an interactively
|
||||
// created app route is never sealed.
|
||||
sealed: false,
|
||||
})
|
||||
.await?;
|
||||
refresh_table(&state).await?;
|
||||
@@ -747,6 +750,7 @@ mod tests {
|
||||
method: None,
|
||||
dispatch_mode: DispatchMode::default(),
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
created_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ pub struct NewRoute {
|
||||
pub dispatch_mode: DispatchMode,
|
||||
/// Three-state lifecycle (§4.3). Create active by default.
|
||||
pub enabled: bool,
|
||||
/// §11 tail: `true` for a sealed (non-suppressible) group route template.
|
||||
/// Always `false` for an app-owned route (the reconcile rejects sealed on
|
||||
/// an app owner before reaching here).
|
||||
pub sealed: bool,
|
||||
}
|
||||
|
||||
/// A route resolved for a specific app via the §11 tail expansion: the route
|
||||
@@ -153,7 +157,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
path_kind, path, method, dispatch_mode, enabled, sealed, created_at \
|
||||
FROM routes WHERE group_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
@@ -190,7 +194,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
SELECT ac.effective_app_id, ac.depth, \
|
||||
r.id, r.app_id, r.group_id, r.script_id, r.host_kind, r.host, \
|
||||
r.host_param_name, r.path_kind, r.path, r.method, \
|
||||
r.dispatch_mode, r.enabled, r.created_at \
|
||||
r.dispatch_mode, r.enabled, r.sealed, r.created_at \
|
||||
FROM app_chain ac \
|
||||
JOIN routes r ON (r.app_id = ac.owner_app OR r.group_id = ac.owner_group) \
|
||||
ORDER BY ac.effective_app_id, ac.depth",
|
||||
@@ -290,10 +294,10 @@ pub(crate) async fn insert_route_tx(
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
app_id, group_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||||
path_kind, path, method, dispatch_mode, enabled, sealed \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) \
|
||||
RETURNING id, app_id, group_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at",
|
||||
path_kind, path, method, dispatch_mode, enabled, sealed, created_at",
|
||||
)
|
||||
.bind(owner_app_id)
|
||||
.bind(owner_group_id)
|
||||
@@ -306,6 +310,7 @@ pub(crate) async fn insert_route_tx(
|
||||
.bind(input.method.as_deref())
|
||||
.bind(input.dispatch_mode.as_str())
|
||||
.bind(input.enabled)
|
||||
.bind(input.sealed)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
@@ -355,6 +360,12 @@ struct RouteRow {
|
||||
method: Option<String>,
|
||||
dispatch_mode: String,
|
||||
enabled: bool,
|
||||
// §11 tail: `default` so a SELECT/RETURNING that omits `sealed` still
|
||||
// hydrates (→ false). The queries that must see a true value — `list_effective`
|
||||
// (the rebuild gate) and the group-route load feeding the apply diff — list
|
||||
// it explicitly.
|
||||
#[sqlx(default)]
|
||||
sealed: bool,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
@@ -391,6 +402,7 @@ impl From<RouteRow> for Route {
|
||||
method: r.method,
|
||||
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
|
||||
enabled: r.enabled,
|
||||
sealed: r.sealed,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ pub struct Trigger {
|
||||
pub name: String,
|
||||
pub kind: TriggerKind,
|
||||
pub enabled: bool,
|
||||
/// §11 tail: `true` for a sealed (non-suppressible) group trigger template.
|
||||
/// Always `false` for an app-owned trigger. Part of the apply diff identity
|
||||
/// so toggling it re-materializes the row.
|
||||
#[serde(default)]
|
||||
pub sealed: bool,
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
pub retry_max_attempts: u32,
|
||||
pub retry_backoff: BackoffShape,
|
||||
@@ -543,6 +548,9 @@ pub(crate) async fn insert_trigger_tx(
|
||||
retry_max_attempts: u32,
|
||||
retry_backoff: BackoffShape,
|
||||
retry_base_ms: u32,
|
||||
// §11 tail: `true` for a sealed (non-suppressible) group trigger template.
|
||||
// Always `false` for an app-owned trigger.
|
||||
sealed: bool,
|
||||
details: &TriggerDetails,
|
||||
) -> Result<TriggerId, TriggerRepoError> {
|
||||
let kind = match details {
|
||||
@@ -595,8 +603,8 @@ pub(crate) async fn insert_trigger_tx(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, group_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, $3, $4, TRUE, $5, $6, $7, $8, $9) RETURNING id",
|
||||
registered_by_principal, sealed \
|
||||
) VALUES ($1, $2, $3, $4, TRUE, $5, $6, $7, $8, $9, $10) RETURNING id",
|
||||
)
|
||||
.bind(owner_app_id)
|
||||
.bind(owner_group_id)
|
||||
@@ -607,6 +615,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(sealed)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
let tid = row.0;
|
||||
@@ -806,6 +815,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Kv,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -873,6 +883,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Docs,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -935,6 +946,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::DeadLetter,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(1),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -1003,6 +1015,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Cron,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -1071,6 +1084,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Files,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -1134,6 +1148,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Pubsub,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -1195,6 +1210,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Email,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -1235,7 +1251,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
|
||||
let parents: Vec<TriggerRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
"SELECT id, app_id, script_id, name, kind, enabled, sealed, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at \
|
||||
FROM triggers WHERE app_id = $1 ORDER BY created_at DESC",
|
||||
@@ -1263,7 +1279,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<Trigger>, TriggerRepoError> {
|
||||
let parents: Vec<TriggerRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, group_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
"SELECT id, app_id, group_id, script_id, name, kind, enabled, sealed, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at \
|
||||
FROM triggers WHERE group_id = $1 ORDER BY created_at DESC",
|
||||
@@ -1289,7 +1305,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
// rows are all app-owned) — else a template would hydrate with both
|
||||
// owners None, breaking the exactly-one invariant in memory.
|
||||
let parent: Option<TriggerRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, group_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
"SELECT id, app_id, group_id, script_id, name, kind, enabled, sealed, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at \
|
||||
FROM triggers WHERE id = $1",
|
||||
@@ -1580,6 +1596,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Queue,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -1789,6 +1806,7 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
|
||||
name: parent.name,
|
||||
kind,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -1837,6 +1855,11 @@ struct TriggerRow {
|
||||
name: String,
|
||||
kind: String,
|
||||
enabled: bool,
|
||||
// `default` (§11 tail) so the interactive-create RETURNING clauses (which
|
||||
// build app-owned rows, never sealed) still hydrate; the group/app list
|
||||
// queries that must see a true value SELECT it explicitly.
|
||||
#[sqlx(default)]
|
||||
sealed: bool,
|
||||
dispatch_mode: String,
|
||||
retry_max_attempts: i32,
|
||||
retry_backoff: String,
|
||||
|
||||
@@ -908,6 +908,7 @@ mod tests {
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Kv,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
retry_max_attempts: req.retry_max_attempts,
|
||||
retry_backoff: req.retry_backoff,
|
||||
@@ -938,6 +939,7 @@ mod tests {
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Docs,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
retry_max_attempts: req.retry_max_attempts,
|
||||
retry_backoff: req.retry_backoff,
|
||||
@@ -968,6 +970,7 @@ mod tests {
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::DeadLetter,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
retry_max_attempts: 1,
|
||||
retry_backoff: BackoffShape::Constant,
|
||||
@@ -999,6 +1002,7 @@ mod tests {
|
||||
name: "mock".into(),
|
||||
kind: TriggerKind::Email,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
retry_max_attempts: 3,
|
||||
retry_backoff: BackoffShape::Exponential,
|
||||
@@ -1045,6 +1049,7 @@ mod tests {
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Cron,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
retry_max_attempts: req.retry_max_attempts,
|
||||
retry_backoff: req.retry_backoff,
|
||||
@@ -1076,6 +1081,7 @@ mod tests {
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Files,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
retry_max_attempts: req.retry_max_attempts,
|
||||
retry_backoff: req.retry_backoff,
|
||||
@@ -1106,6 +1112,7 @@ mod tests {
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Pubsub,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
retry_max_attempts: req.retry_max_attempts,
|
||||
retry_backoff: req.retry_backoff,
|
||||
@@ -1184,6 +1191,7 @@ mod tests {
|
||||
name: "mock".into(),
|
||||
kind: TriggerKind::Queue,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
retry_max_attempts: req.retry_max_attempts,
|
||||
retry_backoff: req.retry_backoff,
|
||||
|
||||
@@ -136,6 +136,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
path: "/hello".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
}],
|
||||
triggers: crate::manifest::ManifestTriggers::default(),
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
|
||||
@@ -94,6 +94,9 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
path: r.path,
|
||||
dispatch_mode: r.dispatch_mode,
|
||||
enabled: r.enabled,
|
||||
// `pull` reconciles an app; app-owned routes are never sealed
|
||||
// (sealing is a group-template property, §11 tail).
|
||||
sealed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -158,6 +161,8 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
// app-owned triggers are never sealed (group-template only).
|
||||
sealed: false,
|
||||
});
|
||||
}
|
||||
"docs" => {
|
||||
@@ -168,6 +173,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
sealed: false,
|
||||
});
|
||||
}
|
||||
"files" => {
|
||||
@@ -178,6 +184,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
sealed: false,
|
||||
});
|
||||
}
|
||||
"cron" => {
|
||||
@@ -197,6 +204,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
topic_pattern: d.topic_pattern,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
sealed: false,
|
||||
});
|
||||
}
|
||||
"queue" => {
|
||||
|
||||
@@ -370,6 +370,11 @@ pub struct ManifestRoute {
|
||||
skip_serializing_if = "is_true"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
/// §11 tail: `sealed = true` on a `[group]` route template makes it
|
||||
/// non-suppressible — a descendant's `[suppress]` cannot decline it.
|
||||
/// Meaningless (rejected at apply) on an `[app]` route. Omitted ⇒ unsealed.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub sealed: bool,
|
||||
}
|
||||
|
||||
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||
@@ -414,6 +419,10 @@ pub struct KvTriggerSpec {
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// §11 tail: `sealed = true` on a `[group]` template makes it
|
||||
/// non-suppressible (event kinds only; group-only). Omitted ⇒ unsealed.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub sealed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -426,6 +435,9 @@ pub struct DocsTriggerSpec {
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// See [`KvTriggerSpec::sealed`].
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub sealed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -438,6 +450,9 @@ pub struct FilesTriggerSpec {
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// See [`KvTriggerSpec::sealed`].
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub sealed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -461,6 +476,9 @@ pub struct PubsubTriggerSpec {
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// See [`KvTriggerSpec::sealed`].
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub sealed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -538,6 +556,11 @@ fn is_true(b: &bool) -> bool {
|
||||
*b
|
||||
}
|
||||
|
||||
/// Skip-serialize helper: `sealed` defaults false, so only emit it when true.
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
"UTC".to_string()
|
||||
}
|
||||
@@ -590,6 +613,7 @@ mod tests {
|
||||
path: "/posts".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
}],
|
||||
triggers: ManifestTriggers {
|
||||
cron: vec![CronTriggerSpec {
|
||||
@@ -605,6 +629,7 @@ mod tests {
|
||||
ops: vec![KvEventOp::Insert, KvEventOp::Update],
|
||||
dispatch_mode: Some(DispatchMode::Async),
|
||||
retry_max_attempts: Some(5),
|
||||
sealed: false,
|
||||
}],
|
||||
..ManifestTriggers::default()
|
||||
},
|
||||
@@ -810,6 +835,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sealed_parses_on_group_route_and_trigger_templates() {
|
||||
// §11 tail: a [group] can mark a route/trigger template `sealed = true`
|
||||
// (non-suppressible). The default is unsealed.
|
||||
let m = Manifest::parse(
|
||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||
[[routes]]\nscript = \"gate\"\npath = \"/health\"\npath_kind = \"exact\"\n\
|
||||
host_kind = \"any\"\nsealed = true\n\n\
|
||||
[[triggers.kv]]\nscript = \"audit\"\ncollection_glob = \"*\"\nsealed = true\n\n\
|
||||
[[triggers.docs]]\nscript = \"log\"\ncollection_glob = \"*\"\n",
|
||||
)
|
||||
.expect("sealed templates parse");
|
||||
assert!(m.routes[0].sealed, "route sealed must parse");
|
||||
assert!(m.triggers.kv[0].sealed, "kv trigger sealed must parse");
|
||||
assert!(
|
||||
!m.triggers.docs[0].sealed,
|
||||
"an omitted sealed defaults to false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_manifest_parses_and_rejects_app_only_blocks() {
|
||||
// A [group] node: scripts + vars, no [app].
|
||||
|
||||
@@ -109,5 +109,13 @@ pub struct Route {
|
||||
#[serde(default = "crate::default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// §11 tail: `true` for a sealed group route TEMPLATE — a descendant app's
|
||||
/// `[suppress]` cannot decline it, so the RouteTable rebuild keeps it in the
|
||||
/// app's slice even at a suppressed path. Always `false` for an app-owned
|
||||
/// route (sealing an un-inherited route is rejected at apply). Consulted
|
||||
/// only at rebuild via `list_effective`; the request hot path never reads it.
|
||||
#[serde(default)]
|
||||
pub sealed: bool,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user