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,
|
method: None,
|
||||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,11 @@ pub struct BundleRoute {
|
|||||||
/// Three-state lifecycle (§4.3); omitted ⇒ active.
|
/// Three-state lifecycle (§4.3); omitted ⇒ active.
|
||||||
#[serde(default = "picloud_shared::default_true")]
|
#[serde(default = "picloud_shared::default_true")]
|
||||||
pub enabled: bool,
|
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", … }`).
|
/// Desired trigger, tagged by kind on the wire (`{ "kind": "cron", … }`).
|
||||||
@@ -174,6 +179,10 @@ pub enum BundleTrigger {
|
|||||||
dispatch_mode: Option<TriggerDispatchMode>,
|
dispatch_mode: Option<TriggerDispatchMode>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
retry_max_attempts: Option<u32>,
|
retry_max_attempts: Option<u32>,
|
||||||
|
/// §11 tail: sealed group template (event kinds only). See
|
||||||
|
/// [`BundleTrigger::sealed`].
|
||||||
|
#[serde(default)]
|
||||||
|
sealed: bool,
|
||||||
},
|
},
|
||||||
Docs {
|
Docs {
|
||||||
script: String,
|
script: String,
|
||||||
@@ -184,6 +193,8 @@ pub enum BundleTrigger {
|
|||||||
dispatch_mode: Option<TriggerDispatchMode>,
|
dispatch_mode: Option<TriggerDispatchMode>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
retry_max_attempts: Option<u32>,
|
retry_max_attempts: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
sealed: bool,
|
||||||
},
|
},
|
||||||
Files {
|
Files {
|
||||||
script: String,
|
script: String,
|
||||||
@@ -194,6 +205,8 @@ pub enum BundleTrigger {
|
|||||||
dispatch_mode: Option<TriggerDispatchMode>,
|
dispatch_mode: Option<TriggerDispatchMode>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
retry_max_attempts: Option<u32>,
|
retry_max_attempts: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
sealed: bool,
|
||||||
},
|
},
|
||||||
Cron {
|
Cron {
|
||||||
script: String,
|
script: String,
|
||||||
@@ -212,6 +225,8 @@ pub enum BundleTrigger {
|
|||||||
dispatch_mode: Option<TriggerDispatchMode>,
|
dispatch_mode: Option<TriggerDispatchMode>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
retry_max_attempts: Option<u32>,
|
retry_max_attempts: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
sealed: bool,
|
||||||
},
|
},
|
||||||
Email {
|
Email {
|
||||||
script: String,
|
script: String,
|
||||||
@@ -262,35 +277,55 @@ impl BundleTrigger {
|
|||||||
matches!(self, Self::Email { .. })
|
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.
|
/// Stable semantic identity (the per-kind tuple), used for the diff.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn identity(&self) -> String {
|
pub fn identity(&self) -> String {
|
||||||
match self {
|
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 {
|
Self::Kv {
|
||||||
script,
|
script,
|
||||||
collection_glob,
|
collection_glob,
|
||||||
ops,
|
ops,
|
||||||
|
sealed,
|
||||||
..
|
..
|
||||||
} => format!(
|
} => format!(
|
||||||
"kv|{script}|{collection_glob}|{}",
|
"kv|{script}|{collection_glob}|{}|{sealed}",
|
||||||
sorted_csv(ops.iter().copied().map(KvEventOp::as_str))
|
sorted_csv(ops.iter().copied().map(KvEventOp::as_str))
|
||||||
),
|
),
|
||||||
Self::Docs {
|
Self::Docs {
|
||||||
script,
|
script,
|
||||||
collection_glob,
|
collection_glob,
|
||||||
ops,
|
ops,
|
||||||
|
sealed,
|
||||||
..
|
..
|
||||||
} => format!(
|
} => format!(
|
||||||
"docs|{script}|{collection_glob}|{}",
|
"docs|{script}|{collection_glob}|{}|{sealed}",
|
||||||
sorted_csv(ops.iter().copied().map(DocsEventOp::as_str))
|
sorted_csv(ops.iter().copied().map(DocsEventOp::as_str))
|
||||||
),
|
),
|
||||||
Self::Files {
|
Self::Files {
|
||||||
script,
|
script,
|
||||||
collection_glob,
|
collection_glob,
|
||||||
ops,
|
ops,
|
||||||
|
sealed,
|
||||||
..
|
..
|
||||||
} => format!(
|
} => format!(
|
||||||
"files|{script}|{collection_glob}|{}",
|
"files|{script}|{collection_glob}|{}|{sealed}",
|
||||||
sorted_csv(ops.iter().copied().map(FilesEventOp::as_str))
|
sorted_csv(ops.iter().copied().map(FilesEventOp::as_str))
|
||||||
),
|
),
|
||||||
Self::Cron {
|
Self::Cron {
|
||||||
@@ -302,8 +337,9 @@ impl BundleTrigger {
|
|||||||
Self::Pubsub {
|
Self::Pubsub {
|
||||||
script,
|
script,
|
||||||
topic_pattern,
|
topic_pattern,
|
||||||
|
sealed,
|
||||||
..
|
..
|
||||||
} => format!("pubsub|{script}|{topic_pattern}"),
|
} => format!("pubsub|{script}|{topic_pattern}|{sealed}"),
|
||||||
Self::Email { script, .. } => format!("email|{script}"),
|
Self::Email { script, .. } => format!("email|{script}"),
|
||||||
Self::Queue { queue_name, .. } => format!("queue|{queue_name}"),
|
Self::Queue { queue_name, .. } => format!("queue|{queue_name}"),
|
||||||
}
|
}
|
||||||
@@ -705,6 +741,28 @@ impl ApplyService {
|
|||||||
.into(),
|
.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
|
// §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
|
// doesn't want a template simply doesn't declare it (the CLI already
|
||||||
// rejects `[suppress]` on a `[group]`; this guards a hand-rolled wire
|
// rejects `[suppress]` on a `[group]`; this guards a hand-rolled wire
|
||||||
@@ -937,6 +995,9 @@ impl ApplyService {
|
|||||||
retry_max,
|
retry_max,
|
||||||
backoff,
|
backoff,
|
||||||
base,
|
base,
|
||||||
|
// §11 tail: a sealed group template is non-suppressible
|
||||||
|
// (rejected on an app owner in `validate_bundle_for`).
|
||||||
|
bt.sealed(),
|
||||||
&details,
|
&details,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -2884,6 +2945,9 @@ fn diff_routes(
|
|||||||
|| cur.dispatch_mode != r.dispatch_mode
|
|| cur.dispatch_mode != r.dispatch_mode
|
||||||
|| cur.host_param_name != r.host_param_name
|
|| cur.host_param_name != r.host_param_name
|
||||||
|| cur.enabled != r.enabled
|
|| 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 {
|
out.push(ResourceChange {
|
||||||
op: Op::Update,
|
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.
|
/// 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> {
|
fn current_trigger_identity(t: &Trigger, name_by_id: &HashMap<ScriptId, String>) -> Option<String> {
|
||||||
let script = name_by_id.get(&t.script_id)?;
|
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 {
|
match &t.details {
|
||||||
TriggerDetails::Kv {
|
TriggerDetails::Kv {
|
||||||
collection_glob,
|
collection_glob,
|
||||||
ops,
|
ops,
|
||||||
} => Some(format!(
|
} => Some(format!(
|
||||||
"kv|{script}|{collection_glob}|{}",
|
"kv|{script}|{collection_glob}|{}|{sealed}",
|
||||||
sorted_csv(ops.iter().copied().map(KvEventOp::as_str))
|
sorted_csv(ops.iter().copied().map(KvEventOp::as_str))
|
||||||
)),
|
)),
|
||||||
TriggerDetails::Docs {
|
TriggerDetails::Docs {
|
||||||
collection_glob,
|
collection_glob,
|
||||||
ops,
|
ops,
|
||||||
} => Some(format!(
|
} => Some(format!(
|
||||||
"docs|{script}|{collection_glob}|{}",
|
"docs|{script}|{collection_glob}|{}|{sealed}",
|
||||||
sorted_csv(ops.iter().copied().map(DocsEventOp::as_str))
|
sorted_csv(ops.iter().copied().map(DocsEventOp::as_str))
|
||||||
)),
|
)),
|
||||||
TriggerDetails::Files {
|
TriggerDetails::Files {
|
||||||
collection_glob,
|
collection_glob,
|
||||||
ops,
|
ops,
|
||||||
} => Some(format!(
|
} => Some(format!(
|
||||||
"files|{script}|{collection_glob}|{}",
|
"files|{script}|{collection_glob}|{}|{sealed}",
|
||||||
sorted_csv(ops.iter().copied().map(FilesEventOp::as_str))
|
sorted_csv(ops.iter().copied().map(FilesEventOp::as_str))
|
||||||
)),
|
)),
|
||||||
TriggerDetails::Cron {
|
TriggerDetails::Cron {
|
||||||
schedule, timezone, ..
|
schedule, timezone, ..
|
||||||
} => Some(format!("cron|{script}|{schedule}|{timezone}")),
|
} => Some(format!("cron|{script}|{schedule}|{timezone}")),
|
||||||
TriggerDetails::Pubsub { topic_pattern } => {
|
TriggerDetails::Pubsub { topic_pattern } => {
|
||||||
Some(format!("pubsub|{script}|{topic_pattern}"))
|
Some(format!("pubsub|{script}|{topic_pattern}|{sealed}"))
|
||||||
}
|
}
|
||||||
TriggerDetails::Email { .. } => Some(format!("email|{script}")),
|
TriggerDetails::Email { .. } => Some(format!("email|{script}")),
|
||||||
TriggerDetails::Queue { queue_name, .. } => Some(format!("queue|{queue_name}")),
|
TriggerDetails::Queue { queue_name, .. } => Some(format!("queue|{queue_name}")),
|
||||||
@@ -3498,6 +3565,9 @@ async fn insert_bundle_route(
|
|||||||
method: br.method.clone(),
|
method: br.method.clone(),
|
||||||
dispatch_mode: br.dispatch_mode,
|
dispatch_mode: br.dispatch_mode,
|
||||||
enabled: br.enabled,
|
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)?;
|
insert_route_tx(tx, &new).await.map_err(map_repo)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -3844,6 +3914,7 @@ mod tests {
|
|||||||
method: Some("POST".into()),
|
method: Some("POST".into()),
|
||||||
dispatch_mode: DispatchMode::Sync,
|
dispatch_mode: DispatchMode::Sync,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
};
|
};
|
||||||
let current = CurrentState {
|
let current = CurrentState {
|
||||||
@@ -3864,6 +3935,7 @@ mod tests {
|
|||||||
path: "/p".into(),
|
path: "/p".into(),
|
||||||
dispatch_mode: DispatchMode::Sync,
|
dispatch_mode: DispatchMode::Sync,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
}],
|
}],
|
||||||
triggers: vec![],
|
triggers: vec![],
|
||||||
secrets: vec![],
|
secrets: vec![],
|
||||||
@@ -3910,6 +3982,7 @@ mod tests {
|
|||||||
name: "t".into(),
|
name: "t".into(),
|
||||||
kind,
|
kind,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
dispatch_mode: TriggerDispatchMode::Async,
|
dispatch_mode: TriggerDispatchMode::Async,
|
||||||
retry_max_attempts: 3,
|
retry_max_attempts: 3,
|
||||||
retry_backoff: BackoffShape::Exponential,
|
retry_backoff: BackoffShape::Exponential,
|
||||||
@@ -3989,6 +4062,7 @@ mod tests {
|
|||||||
ops: vec![],
|
ops: vec![],
|
||||||
dispatch_mode: None,
|
dispatch_mode: None,
|
||||||
retry_max_attempts: None,
|
retry_max_attempts: None,
|
||||||
|
sealed: false,
|
||||||
})
|
})
|
||||||
.is_err());
|
.is_err());
|
||||||
// A non-empty glob passes.
|
// A non-empty glob passes.
|
||||||
@@ -3998,6 +4072,7 @@ mod tests {
|
|||||||
ops: vec![],
|
ops: vec![],
|
||||||
dispatch_mode: None,
|
dispatch_mode: None,
|
||||||
retry_max_attempts: None,
|
retry_max_attempts: None,
|
||||||
|
sealed: false,
|
||||||
})
|
})
|
||||||
.is_ok());
|
.is_ok());
|
||||||
// A malformed pubsub topic_pattern (mid-pattern wildcard) is rejected;
|
// A malformed pubsub topic_pattern (mid-pattern wildcard) is rejected;
|
||||||
@@ -4007,6 +4082,7 @@ mod tests {
|
|||||||
topic_pattern: "user.*.created".into(),
|
topic_pattern: "user.*.created".into(),
|
||||||
dispatch_mode: None,
|
dispatch_mode: None,
|
||||||
retry_max_attempts: None,
|
retry_max_attempts: None,
|
||||||
|
sealed: false,
|
||||||
})
|
})
|
||||||
.is_err());
|
.is_err());
|
||||||
assert!(validate_trigger_shape(&BundleTrigger::Pubsub {
|
assert!(validate_trigger_shape(&BundleTrigger::Pubsub {
|
||||||
@@ -4014,6 +4090,7 @@ mod tests {
|
|||||||
topic_pattern: "orders.created".into(),
|
topic_pattern: "orders.created".into(),
|
||||||
dispatch_mode: None,
|
dispatch_mode: None,
|
||||||
retry_max_attempts: None,
|
retry_max_attempts: None,
|
||||||
|
sealed: false,
|
||||||
})
|
})
|
||||||
.is_ok());
|
.is_ok());
|
||||||
// Queue visibility floor matches the interactive API (>= 30): a value
|
// Queue visibility floor matches the interactive API (>= 30): a value
|
||||||
@@ -4209,6 +4286,7 @@ mod tests {
|
|||||||
path: "/h".into(),
|
path: "/h".into(),
|
||||||
dispatch_mode: DispatchMode::Sync,
|
dispatch_mode: DispatchMode::Sync,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
}];
|
}];
|
||||||
let w = disabled_target_warnings(&b);
|
let w = disabled_target_warnings(&b);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -4241,6 +4319,7 @@ mod tests {
|
|||||||
path: "/o".into(),
|
path: "/o".into(),
|
||||||
dispatch_mode: DispatchMode::Sync,
|
dispatch_mode: DispatchMode::Sync,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
}];
|
}];
|
||||||
assert!(unreachable_endpoint_warnings(&b).is_empty());
|
assert!(unreachable_endpoint_warnings(&b).is_empty());
|
||||||
// A module is exempt even with no binding.
|
// A module is exempt even with no binding.
|
||||||
@@ -4311,6 +4390,7 @@ mod tests {
|
|||||||
ops: vec![KvEventOp::Insert, KvEventOp::Update], // reversed
|
ops: vec![KvEventOp::Insert, KvEventOp::Update], // reversed
|
||||||
dispatch_mode: None,
|
dispatch_mode: None,
|
||||||
retry_max_attempts: None,
|
retry_max_attempts: None,
|
||||||
|
sealed: false,
|
||||||
}];
|
}];
|
||||||
let p = compute_diff(¤t, &b);
|
let p = compute_diff(¤t, &b);
|
||||||
assert!(
|
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]
|
#[test]
|
||||||
fn dead_letter_trigger_is_ignored_by_diff() {
|
fn dead_letter_trigger_is_ignored_by_diff() {
|
||||||
// Dead-letter triggers can't be expressed in the manifest, so the
|
// 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,
|
dispatch_mode: input.dispatch_mode,
|
||||||
// Routes are created active; toggling is a dedicated path.
|
// Routes are created active; toggling is a dedicated path.
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
// Sealing is a group-template property (§11 tail); an interactively
|
||||||
|
// created app route is never sealed.
|
||||||
|
sealed: false,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
refresh_table(&state).await?;
|
refresh_table(&state).await?;
|
||||||
@@ -747,6 +750,7 @@ mod tests {
|
|||||||
method: None,
|
method: None,
|
||||||
dispatch_mode: DispatchMode::default(),
|
dispatch_mode: DispatchMode::default(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ pub struct NewRoute {
|
|||||||
pub dispatch_mode: DispatchMode,
|
pub dispatch_mode: DispatchMode,
|
||||||
/// Three-state lifecycle (§4.3). Create active by default.
|
/// Three-state lifecycle (§4.3). Create active by default.
|
||||||
pub enabled: bool,
|
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
|
/// 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> {
|
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, RouteRow>(
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
"SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \
|
"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",
|
FROM routes WHERE group_id = $1 ORDER BY created_at",
|
||||||
)
|
)
|
||||||
.bind(group_id.into_inner())
|
.bind(group_id.into_inner())
|
||||||
@@ -190,7 +194,7 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
SELECT ac.effective_app_id, ac.depth, \
|
SELECT ac.effective_app_id, ac.depth, \
|
||||||
r.id, r.app_id, r.group_id, r.script_id, r.host_kind, r.host, \
|
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.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 \
|
FROM app_chain ac \
|
||||||
JOIN routes r ON (r.app_id = ac.owner_app OR r.group_id = ac.owner_group) \
|
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",
|
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>(
|
let res = sqlx::query_as::<_, RouteRow>(
|
||||||
"INSERT INTO routes ( \
|
"INSERT INTO routes ( \
|
||||||
app_id, group_id, script_id, host_kind, host, host_param_name, \
|
app_id, group_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, dispatch_mode, enabled \
|
path_kind, path, method, dispatch_mode, enabled, sealed \
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
) 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, \
|
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_app_id)
|
||||||
.bind(owner_group_id)
|
.bind(owner_group_id)
|
||||||
@@ -306,6 +310,7 @@ pub(crate) async fn insert_route_tx(
|
|||||||
.bind(input.method.as_deref())
|
.bind(input.method.as_deref())
|
||||||
.bind(input.dispatch_mode.as_str())
|
.bind(input.dispatch_mode.as_str())
|
||||||
.bind(input.enabled)
|
.bind(input.enabled)
|
||||||
|
.bind(input.sealed)
|
||||||
.fetch_one(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
.await;
|
.await;
|
||||||
match res {
|
match res {
|
||||||
@@ -355,6 +360,12 @@ struct RouteRow {
|
|||||||
method: Option<String>,
|
method: Option<String>,
|
||||||
dispatch_mode: String,
|
dispatch_mode: String,
|
||||||
enabled: bool,
|
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>,
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +402,7 @@ impl From<RouteRow> for Route {
|
|||||||
method: r.method,
|
method: r.method,
|
||||||
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
|
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
|
||||||
enabled: r.enabled,
|
enabled: r.enabled,
|
||||||
|
sealed: r.sealed,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ pub struct Trigger {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub kind: TriggerKind,
|
pub kind: TriggerKind,
|
||||||
pub enabled: bool,
|
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 dispatch_mode: TriggerDispatchMode,
|
||||||
pub retry_max_attempts: u32,
|
pub retry_max_attempts: u32,
|
||||||
pub retry_backoff: BackoffShape,
|
pub retry_backoff: BackoffShape,
|
||||||
@@ -543,6 +548,9 @@ pub(crate) async fn insert_trigger_tx(
|
|||||||
retry_max_attempts: u32,
|
retry_max_attempts: u32,
|
||||||
retry_backoff: BackoffShape,
|
retry_backoff: BackoffShape,
|
||||||
retry_base_ms: u32,
|
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,
|
details: &TriggerDetails,
|
||||||
) -> Result<TriggerId, TriggerRepoError> {
|
) -> Result<TriggerId, TriggerRepoError> {
|
||||||
let kind = match details {
|
let kind = match details {
|
||||||
@@ -595,8 +603,8 @@ pub(crate) async fn insert_trigger_tx(
|
|||||||
"INSERT INTO triggers ( \
|
"INSERT INTO triggers ( \
|
||||||
app_id, group_id, script_id, kind, enabled, dispatch_mode, \
|
app_id, group_id, script_id, kind, enabled, dispatch_mode, \
|
||||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||||
registered_by_principal \
|
registered_by_principal, sealed \
|
||||||
) VALUES ($1, $2, $3, $4, TRUE, $5, $6, $7, $8, $9) RETURNING id",
|
) VALUES ($1, $2, $3, $4, TRUE, $5, $6, $7, $8, $9, $10) RETURNING id",
|
||||||
)
|
)
|
||||||
.bind(owner_app_id)
|
.bind(owner_app_id)
|
||||||
.bind(owner_group_id)
|
.bind(owner_group_id)
|
||||||
@@ -607,6 +615,7 @@ pub(crate) async fn insert_trigger_tx(
|
|||||||
.bind(retry_backoff.as_str())
|
.bind(retry_backoff.as_str())
|
||||||
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
|
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
|
||||||
.bind(registered_by.into_inner())
|
.bind(registered_by.into_inner())
|
||||||
|
.bind(sealed)
|
||||||
.fetch_one(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
let tid = row.0;
|
let tid = row.0;
|
||||||
@@ -806,6 +815,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
name: parent.name.clone(),
|
name: parent.name.clone(),
|
||||||
kind: TriggerKind::Kv,
|
kind: TriggerKind::Kv,
|
||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
|
sealed: parent.sealed,
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -873,6 +883,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
name: parent.name.clone(),
|
name: parent.name.clone(),
|
||||||
kind: TriggerKind::Docs,
|
kind: TriggerKind::Docs,
|
||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
|
sealed: parent.sealed,
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -935,6 +946,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
name: parent.name.clone(),
|
name: parent.name.clone(),
|
||||||
kind: TriggerKind::DeadLetter,
|
kind: TriggerKind::DeadLetter,
|
||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
|
sealed: parent.sealed,
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(1),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(1),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -1003,6 +1015,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
name: parent.name.clone(),
|
name: parent.name.clone(),
|
||||||
kind: TriggerKind::Cron,
|
kind: TriggerKind::Cron,
|
||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
|
sealed: parent.sealed,
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -1071,6 +1084,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
name: parent.name.clone(),
|
name: parent.name.clone(),
|
||||||
kind: TriggerKind::Files,
|
kind: TriggerKind::Files,
|
||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
|
sealed: parent.sealed,
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -1134,6 +1148,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
name: parent.name.clone(),
|
name: parent.name.clone(),
|
||||||
kind: TriggerKind::Pubsub,
|
kind: TriggerKind::Pubsub,
|
||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
|
sealed: parent.sealed,
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -1195,6 +1210,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
name: parent.name.clone(),
|
name: parent.name.clone(),
|
||||||
kind: TriggerKind::Email,
|
kind: TriggerKind::Email,
|
||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
|
sealed: parent.sealed,
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
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> {
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
|
||||||
let parents: Vec<TriggerRow> = sqlx::query_as(
|
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, \
|
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||||
registered_by_principal, created_at, updated_at \
|
registered_by_principal, created_at, updated_at \
|
||||||
FROM triggers WHERE app_id = $1 ORDER BY created_at DESC",
|
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> {
|
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<Trigger>, TriggerRepoError> {
|
||||||
let parents: Vec<TriggerRow> = sqlx::query_as(
|
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, \
|
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||||
registered_by_principal, created_at, updated_at \
|
registered_by_principal, created_at, updated_at \
|
||||||
FROM triggers WHERE group_id = $1 ORDER BY created_at DESC",
|
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
|
// rows are all app-owned) — else a template would hydrate with both
|
||||||
// owners None, breaking the exactly-one invariant in memory.
|
// owners None, breaking the exactly-one invariant in memory.
|
||||||
let parent: Option<TriggerRow> = sqlx::query_as(
|
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, \
|
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||||
registered_by_principal, created_at, updated_at \
|
registered_by_principal, created_at, updated_at \
|
||||||
FROM triggers WHERE id = $1",
|
FROM triggers WHERE id = $1",
|
||||||
@@ -1580,6 +1596,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
name: parent.name.clone(),
|
name: parent.name.clone(),
|
||||||
kind: TriggerKind::Queue,
|
kind: TriggerKind::Queue,
|
||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
|
sealed: parent.sealed,
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
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,
|
name: parent.name,
|
||||||
kind,
|
kind,
|
||||||
enabled: parent.enabled,
|
enabled: parent.enabled,
|
||||||
|
sealed: parent.sealed,
|
||||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||||
@@ -1837,6 +1855,11 @@ struct TriggerRow {
|
|||||||
name: String,
|
name: String,
|
||||||
kind: String,
|
kind: String,
|
||||||
enabled: bool,
|
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,
|
dispatch_mode: String,
|
||||||
retry_max_attempts: i32,
|
retry_max_attempts: i32,
|
||||||
retry_backoff: String,
|
retry_backoff: String,
|
||||||
|
|||||||
@@ -908,6 +908,7 @@ mod tests {
|
|||||||
name: "mock".into(),
|
name: "mock".into(),
|
||||||
kind: crate::trigger_repo::TriggerKind::Kv,
|
kind: crate::trigger_repo::TriggerKind::Kv,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
dispatch_mode: req.dispatch_mode,
|
dispatch_mode: req.dispatch_mode,
|
||||||
retry_max_attempts: req.retry_max_attempts,
|
retry_max_attempts: req.retry_max_attempts,
|
||||||
retry_backoff: req.retry_backoff,
|
retry_backoff: req.retry_backoff,
|
||||||
@@ -938,6 +939,7 @@ mod tests {
|
|||||||
name: "mock".into(),
|
name: "mock".into(),
|
||||||
kind: crate::trigger_repo::TriggerKind::Docs,
|
kind: crate::trigger_repo::TriggerKind::Docs,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
dispatch_mode: req.dispatch_mode,
|
dispatch_mode: req.dispatch_mode,
|
||||||
retry_max_attempts: req.retry_max_attempts,
|
retry_max_attempts: req.retry_max_attempts,
|
||||||
retry_backoff: req.retry_backoff,
|
retry_backoff: req.retry_backoff,
|
||||||
@@ -968,6 +970,7 @@ mod tests {
|
|||||||
name: "mock".into(),
|
name: "mock".into(),
|
||||||
kind: crate::trigger_repo::TriggerKind::DeadLetter,
|
kind: crate::trigger_repo::TriggerKind::DeadLetter,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
dispatch_mode: TriggerDispatchMode::Async,
|
dispatch_mode: TriggerDispatchMode::Async,
|
||||||
retry_max_attempts: 1,
|
retry_max_attempts: 1,
|
||||||
retry_backoff: BackoffShape::Constant,
|
retry_backoff: BackoffShape::Constant,
|
||||||
@@ -999,6 +1002,7 @@ mod tests {
|
|||||||
name: "mock".into(),
|
name: "mock".into(),
|
||||||
kind: TriggerKind::Email,
|
kind: TriggerKind::Email,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
dispatch_mode: TriggerDispatchMode::Async,
|
dispatch_mode: TriggerDispatchMode::Async,
|
||||||
retry_max_attempts: 3,
|
retry_max_attempts: 3,
|
||||||
retry_backoff: BackoffShape::Exponential,
|
retry_backoff: BackoffShape::Exponential,
|
||||||
@@ -1045,6 +1049,7 @@ mod tests {
|
|||||||
name: "mock".into(),
|
name: "mock".into(),
|
||||||
kind: crate::trigger_repo::TriggerKind::Cron,
|
kind: crate::trigger_repo::TriggerKind::Cron,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
dispatch_mode: req.dispatch_mode,
|
dispatch_mode: req.dispatch_mode,
|
||||||
retry_max_attempts: req.retry_max_attempts,
|
retry_max_attempts: req.retry_max_attempts,
|
||||||
retry_backoff: req.retry_backoff,
|
retry_backoff: req.retry_backoff,
|
||||||
@@ -1076,6 +1081,7 @@ mod tests {
|
|||||||
name: "mock".into(),
|
name: "mock".into(),
|
||||||
kind: crate::trigger_repo::TriggerKind::Files,
|
kind: crate::trigger_repo::TriggerKind::Files,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
dispatch_mode: req.dispatch_mode,
|
dispatch_mode: req.dispatch_mode,
|
||||||
retry_max_attempts: req.retry_max_attempts,
|
retry_max_attempts: req.retry_max_attempts,
|
||||||
retry_backoff: req.retry_backoff,
|
retry_backoff: req.retry_backoff,
|
||||||
@@ -1106,6 +1112,7 @@ mod tests {
|
|||||||
name: "mock".into(),
|
name: "mock".into(),
|
||||||
kind: crate::trigger_repo::TriggerKind::Pubsub,
|
kind: crate::trigger_repo::TriggerKind::Pubsub,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
dispatch_mode: req.dispatch_mode,
|
dispatch_mode: req.dispatch_mode,
|
||||||
retry_max_attempts: req.retry_max_attempts,
|
retry_max_attempts: req.retry_max_attempts,
|
||||||
retry_backoff: req.retry_backoff,
|
retry_backoff: req.retry_backoff,
|
||||||
@@ -1184,6 +1191,7 @@ mod tests {
|
|||||||
name: "mock".into(),
|
name: "mock".into(),
|
||||||
kind: TriggerKind::Queue,
|
kind: TriggerKind::Queue,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
dispatch_mode: req.dispatch_mode,
|
dispatch_mode: req.dispatch_mode,
|
||||||
retry_max_attempts: req.retry_max_attempts,
|
retry_max_attempts: req.retry_max_attempts,
|
||||||
retry_backoff: req.retry_backoff,
|
retry_backoff: req.retry_backoff,
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
|||||||
path: "/hello".into(),
|
path: "/hello".into(),
|
||||||
dispatch_mode: DispatchMode::Sync,
|
dispatch_mode: DispatchMode::Sync,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
}],
|
}],
|
||||||
triggers: crate::manifest::ManifestTriggers::default(),
|
triggers: crate::manifest::ManifestTriggers::default(),
|
||||||
secrets: crate::manifest::ManifestSecrets::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,
|
path: r.path,
|
||||||
dispatch_mode: r.dispatch_mode,
|
dispatch_mode: r.dispatch_mode,
|
||||||
enabled: r.enabled,
|
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,
|
ops: d.ops,
|
||||||
dispatch_mode,
|
dispatch_mode,
|
||||||
retry_max_attempts,
|
retry_max_attempts,
|
||||||
|
// app-owned triggers are never sealed (group-template only).
|
||||||
|
sealed: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"docs" => {
|
"docs" => {
|
||||||
@@ -168,6 +173,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
|||||||
ops: d.ops,
|
ops: d.ops,
|
||||||
dispatch_mode,
|
dispatch_mode,
|
||||||
retry_max_attempts,
|
retry_max_attempts,
|
||||||
|
sealed: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"files" => {
|
"files" => {
|
||||||
@@ -178,6 +184,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
|||||||
ops: d.ops,
|
ops: d.ops,
|
||||||
dispatch_mode,
|
dispatch_mode,
|
||||||
retry_max_attempts,
|
retry_max_attempts,
|
||||||
|
sealed: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"cron" => {
|
"cron" => {
|
||||||
@@ -197,6 +204,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
|||||||
topic_pattern: d.topic_pattern,
|
topic_pattern: d.topic_pattern,
|
||||||
dispatch_mode,
|
dispatch_mode,
|
||||||
retry_max_attempts,
|
retry_max_attempts,
|
||||||
|
sealed: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"queue" => {
|
"queue" => {
|
||||||
|
|||||||
@@ -370,6 +370,11 @@ pub struct ManifestRoute {
|
|||||||
skip_serializing_if = "is_true"
|
skip_serializing_if = "is_true"
|
||||||
)]
|
)]
|
||||||
pub enabled: bool,
|
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]]`, …).
|
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||||
@@ -414,6 +419,10 @@ pub struct KvTriggerSpec {
|
|||||||
pub dispatch_mode: Option<DispatchMode>,
|
pub dispatch_mode: Option<DispatchMode>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub retry_max_attempts: Option<u32>,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -426,6 +435,9 @@ pub struct DocsTriggerSpec {
|
|||||||
pub dispatch_mode: Option<DispatchMode>,
|
pub dispatch_mode: Option<DispatchMode>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub retry_max_attempts: Option<u32>,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -438,6 +450,9 @@ pub struct FilesTriggerSpec {
|
|||||||
pub dispatch_mode: Option<DispatchMode>,
|
pub dispatch_mode: Option<DispatchMode>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub retry_max_attempts: Option<u32>,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -461,6 +476,9 @@ pub struct PubsubTriggerSpec {
|
|||||||
pub dispatch_mode: Option<DispatchMode>,
|
pub dispatch_mode: Option<DispatchMode>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub retry_max_attempts: Option<u32>,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -538,6 +556,11 @@ fn is_true(b: &bool) -> bool {
|
|||||||
*b
|
*b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Skip-serialize helper: `sealed` defaults false, so only emit it when true.
|
||||||
|
fn is_false(b: &bool) -> bool {
|
||||||
|
!*b
|
||||||
|
}
|
||||||
|
|
||||||
fn default_timezone() -> String {
|
fn default_timezone() -> String {
|
||||||
"UTC".to_string()
|
"UTC".to_string()
|
||||||
}
|
}
|
||||||
@@ -590,6 +613,7 @@ mod tests {
|
|||||||
path: "/posts".into(),
|
path: "/posts".into(),
|
||||||
dispatch_mode: DispatchMode::Sync,
|
dispatch_mode: DispatchMode::Sync,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sealed: false,
|
||||||
}],
|
}],
|
||||||
triggers: ManifestTriggers {
|
triggers: ManifestTriggers {
|
||||||
cron: vec![CronTriggerSpec {
|
cron: vec![CronTriggerSpec {
|
||||||
@@ -605,6 +629,7 @@ mod tests {
|
|||||||
ops: vec![KvEventOp::Insert, KvEventOp::Update],
|
ops: vec![KvEventOp::Insert, KvEventOp::Update],
|
||||||
dispatch_mode: Some(DispatchMode::Async),
|
dispatch_mode: Some(DispatchMode::Async),
|
||||||
retry_max_attempts: Some(5),
|
retry_max_attempts: Some(5),
|
||||||
|
sealed: false,
|
||||||
}],
|
}],
|
||||||
..ManifestTriggers::default()
|
..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]
|
#[test]
|
||||||
fn group_manifest_parses_and_rejects_app_only_blocks() {
|
fn group_manifest_parses_and_rejects_app_only_blocks() {
|
||||||
// A [group] node: scripts + vars, no [app].
|
// A [group] node: scripts + vars, no [app].
|
||||||
|
|||||||
@@ -109,5 +109,13 @@ pub struct Route {
|
|||||||
#[serde(default = "crate::default_true")]
|
#[serde(default = "crate::default_true")]
|
||||||
pub enabled: bool,
|
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>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user