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:
MechaCat02
2026-07-01 19:32:20 +02:00
parent 483e4cb116
commit 95f20b4add
10 changed files with 298 additions and 18 deletions

View File

@@ -88,6 +88,7 @@ async fn seed_into(
method: None,
dispatch_mode: picloud_shared::DispatchMode::Sync,
enabled: true,
sealed: false,
})
.await?;

View File

@@ -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(&current, &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(&current, &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(&current, &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

View File

@@ -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(),
}
}

View File

@@ -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,
}
}

View File

@@ -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,

View File

@@ -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,