feat(shared-triggers): author + persist + validate shared templates (M2.4)
`shared = true` on a group [[triggers.kv|docs|files]] flows manifest → Bundle → insert_trigger_tx (new shared column), mirroring sealed: shared lives on the Trigger DTO + is part of the apply diff identity (bundle + current sides) so a toggle re-applies. validate_bundle_for rejects shared on an app owner, on a non-collection kind (pubsub/cron/etc.), and on a concrete collection the group does not declare as a shared collection of that kind. Emission (M2.3) now has authored triggers to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -183,6 +183,9 @@ pub enum BundleTrigger {
|
||||
/// [`BundleTrigger::sealed`].
|
||||
#[serde(default)]
|
||||
sealed: bool,
|
||||
/// §11.6: shared-collection group trigger. See [`BundleTrigger::shared`].
|
||||
#[serde(default)]
|
||||
shared: bool,
|
||||
},
|
||||
Docs {
|
||||
script: String,
|
||||
@@ -195,6 +198,8 @@ pub enum BundleTrigger {
|
||||
retry_max_attempts: Option<u32>,
|
||||
#[serde(default)]
|
||||
sealed: bool,
|
||||
#[serde(default)]
|
||||
shared: bool,
|
||||
},
|
||||
Files {
|
||||
script: String,
|
||||
@@ -207,6 +212,8 @@ pub enum BundleTrigger {
|
||||
retry_max_attempts: Option<u32>,
|
||||
#[serde(default)]
|
||||
sealed: bool,
|
||||
#[serde(default)]
|
||||
shared: bool,
|
||||
},
|
||||
Cron {
|
||||
script: String,
|
||||
@@ -291,6 +298,39 @@ impl BundleTrigger {
|
||||
}
|
||||
}
|
||||
|
||||
/// §11.6: whether this template watches a SHARED collection. Only the
|
||||
/// collection-scoped event kinds (kv/docs/files) can be shared; pubsub +
|
||||
/// the app-only kinds are never shared.
|
||||
#[must_use]
|
||||
pub fn shared(&self) -> bool {
|
||||
match self {
|
||||
Self::Kv { shared, .. } | Self::Docs { shared, .. } | Self::Files { shared, .. } => {
|
||||
*shared
|
||||
}
|
||||
Self::Cron { .. } | Self::Pubsub { .. } | Self::Email { .. } | Self::Queue { .. } => {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The watched collection glob for the collection-scoped kinds (kv/docs/
|
||||
/// files); `None` for the others.
|
||||
#[must_use]
|
||||
pub fn collection_glob(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Kv {
|
||||
collection_glob, ..
|
||||
}
|
||||
| Self::Docs {
|
||||
collection_glob, ..
|
||||
}
|
||||
| Self::Files {
|
||||
collection_glob, ..
|
||||
} => Some(collection_glob),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable semantic identity (the per-kind tuple), used for the diff.
|
||||
#[must_use]
|
||||
pub fn identity(&self) -> String {
|
||||
@@ -303,9 +343,10 @@ impl BundleTrigger {
|
||||
collection_glob,
|
||||
ops,
|
||||
sealed,
|
||||
shared,
|
||||
..
|
||||
} => format!(
|
||||
"kv|{script}|{collection_glob}|{}|{sealed}",
|
||||
"kv|{script}|{collection_glob}|{}|{sealed}|{shared}",
|
||||
sorted_csv(ops.iter().copied().map(KvEventOp::as_str))
|
||||
),
|
||||
Self::Docs {
|
||||
@@ -313,9 +354,10 @@ impl BundleTrigger {
|
||||
collection_glob,
|
||||
ops,
|
||||
sealed,
|
||||
shared,
|
||||
..
|
||||
} => format!(
|
||||
"docs|{script}|{collection_glob}|{}|{sealed}",
|
||||
"docs|{script}|{collection_glob}|{}|{sealed}|{shared}",
|
||||
sorted_csv(ops.iter().copied().map(DocsEventOp::as_str))
|
||||
),
|
||||
Self::Files {
|
||||
@@ -323,9 +365,10 @@ impl BundleTrigger {
|
||||
collection_glob,
|
||||
ops,
|
||||
sealed,
|
||||
shared,
|
||||
..
|
||||
} => format!(
|
||||
"files|{script}|{collection_glob}|{}|{sealed}",
|
||||
"files|{script}|{collection_glob}|{}|{sealed}|{shared}",
|
||||
sorted_csv(ops.iter().copied().map(FilesEventOp::as_str))
|
||||
),
|
||||
Self::Cron {
|
||||
@@ -730,6 +773,40 @@ impl ApplyService {
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
// §11.6: a `shared` trigger watches the group's SHARED collection.
|
||||
// Only the collection-scoped kinds can be shared (pubsub has no
|
||||
// shared topic store yet), and the watched collection must be one
|
||||
// the SAME group declares shared, of the matching kind.
|
||||
if t.shared() {
|
||||
let kind = t.kind_str();
|
||||
if !matches!(
|
||||
t,
|
||||
BundleTrigger::Kv { .. }
|
||||
| BundleTrigger::Docs { .. }
|
||||
| BundleTrigger::Files { .. }
|
||||
) {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"a `shared` trigger must be a kv/docs/files kind; `{kind}` \
|
||||
has no shared collection store"
|
||||
)));
|
||||
}
|
||||
let glob = t.collection_glob().unwrap_or_default();
|
||||
// A concrete (non-glob) collection must be declared shared of
|
||||
// the same kind on this group. A wildcard glob can't be
|
||||
// statically resolved, so it's allowed (runtime match).
|
||||
let is_wildcard = glob.contains('*');
|
||||
let declared = bundle
|
||||
.collections
|
||||
.iter()
|
||||
.any(|c| c.kind == kind && c.name.eq_ignore_ascii_case(glob));
|
||||
if !is_wildcard && !declared {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"a `shared` {kind} trigger watches collection `{glob}`, \
|
||||
which this group does not declare as a shared {kind} \
|
||||
collection (add it to `collections`)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// §11.6: shared collections are owned by GROUPS. Reject them on an app
|
||||
@@ -766,6 +843,15 @@ impl ApplyService {
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
// §11.6: `shared` watches a group's SHARED collection — meaningless
|
||||
// for an app (apps don't own shared collections).
|
||||
if bundle.triggers.iter().any(BundleTrigger::shared) {
|
||||
return Err(ApplyError::Invalid(
|
||||
"an app trigger cannot be `shared` — a shared-collection \
|
||||
trigger is owned by the group that declares the collection"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
// §11 tail M1: both an app and a group may declare suppressions — an
|
||||
// app declines an inherited template for itself, a group for its whole
|
||||
@@ -993,6 +1079,8 @@ impl ApplyService {
|
||||
// §11 tail: a sealed group template is non-suppressible
|
||||
// (rejected on an app owner in `validate_bundle_for`).
|
||||
bt.sealed(),
|
||||
// §11.6: a shared-collection group trigger.
|
||||
bt.shared(),
|
||||
&details,
|
||||
)
|
||||
.await
|
||||
@@ -3267,29 +3355,31 @@ 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.
|
||||
// §11 tail: `sealed` (and §11.6 `shared`) are part of the identity for the
|
||||
// event kinds (mirroring `BundleTrigger::identity`), so toggling either on a
|
||||
// group template re-diffs.
|
||||
let sealed = t.sealed;
|
||||
let shared = t.shared;
|
||||
match &t.details {
|
||||
TriggerDetails::Kv {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => Some(format!(
|
||||
"kv|{script}|{collection_glob}|{}|{sealed}",
|
||||
"kv|{script}|{collection_glob}|{}|{sealed}|{shared}",
|
||||
sorted_csv(ops.iter().copied().map(KvEventOp::as_str))
|
||||
)),
|
||||
TriggerDetails::Docs {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => Some(format!(
|
||||
"docs|{script}|{collection_glob}|{}|{sealed}",
|
||||
"docs|{script}|{collection_glob}|{}|{sealed}|{shared}",
|
||||
sorted_csv(ops.iter().copied().map(DocsEventOp::as_str))
|
||||
)),
|
||||
TriggerDetails::Files {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => Some(format!(
|
||||
"files|{script}|{collection_glob}|{}|{sealed}",
|
||||
"files|{script}|{collection_glob}|{}|{sealed}|{shared}",
|
||||
sorted_csv(ops.iter().copied().map(FilesEventOp::as_str))
|
||||
)),
|
||||
TriggerDetails::Cron {
|
||||
@@ -4013,6 +4103,7 @@ mod tests {
|
||||
kind,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
shared: false,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
retry_max_attempts: 3,
|
||||
retry_backoff: BackoffShape::Exponential,
|
||||
@@ -4093,6 +4184,7 @@ mod tests {
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
sealed: false,
|
||||
shared: false,
|
||||
})
|
||||
.is_err());
|
||||
// A non-empty glob passes.
|
||||
@@ -4103,6 +4195,7 @@ mod tests {
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
sealed: false,
|
||||
shared: false,
|
||||
})
|
||||
.is_ok());
|
||||
// A malformed pubsub topic_pattern (mid-pattern wildcard) is rejected;
|
||||
@@ -4421,6 +4514,7 @@ mod tests {
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
sealed: false,
|
||||
shared: false,
|
||||
}];
|
||||
let p = compute_diff(¤t, &b);
|
||||
assert!(
|
||||
@@ -4458,6 +4552,7 @@ mod tests {
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
sealed: true,
|
||||
shared: false,
|
||||
}];
|
||||
let p = compute_diff(¤t, &b);
|
||||
assert!(
|
||||
|
||||
Reference in New Issue
Block a user