feat(shared-topics): thread the shared flag through pubsub triggers (D2)

Add `shared` to BundleTrigger::Pubsub + PubsubTriggerSpec + the trigger
identity (so toggling re-diffs) + current_trigger_identity. validate_bundle_for
now allows a `shared` pubsub trigger on a group and requires the topic
pattern's ROOT segment (events.* -> events) be a declared kind='topic'
collection; a wildcard root is a runtime match. Apply-side plumbing only —
the shared publish path + dispatch boundary land next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-02 21:37:31 +02:00
parent ae8f0be748
commit 1c7e8886d8
3 changed files with 54 additions and 35 deletions

View File

@@ -234,6 +234,11 @@ pub enum BundleTrigger {
retry_max_attempts: Option<u32>,
#[serde(default)]
sealed: bool,
/// §11.6 D2: `true` for a shared-TOPIC group trigger — fires when a
/// subtree app publishes to the group's shared topic namespace, not on
/// per-app publishes.
#[serde(default)]
shared: bool,
},
Email {
script: String,
@@ -298,18 +303,18 @@ 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.
/// §11.6: whether this template watches a SHARED collection. The collection-
/// scoped event kinds (kv/docs/files) and pubsub (D2: a shared TOPIC) can be
/// shared; cron/email/queue are never shared here (a shared queue consumer
/// is authored as a `[[triggers.queue]]` and handled via materialization).
#[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
}
Self::Kv { shared, .. }
| Self::Docs { shared, .. }
| Self::Files { shared, .. }
| Self::Pubsub { shared, .. } => *shared,
Self::Cron { .. } | Self::Email { .. } | Self::Queue { .. } => false,
}
}
@@ -381,8 +386,9 @@ impl BundleTrigger {
script,
topic_pattern,
sealed,
shared,
..
} => format!("pubsub|{script}|{topic_pattern}|{sealed}"),
} => format!("pubsub|{script}|{topic_pattern}|{sealed}|{shared}"),
Self::Email { script, .. } => format!("email|{script}"),
Self::Queue { queue_name, .. } => format!("queue|{queue_name}"),
}
@@ -764,37 +770,41 @@ impl ApplyService {
// store once at apply (shared-group-secret model); materialization
// copies the sealed bytes verbatim.
for t in &bundle.triggers {
// §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.
// §11.6: a `shared` trigger watches a SHARED collection/namespace.
// kv/docs/files watch a shared collection (name = the glob); D2
// pubsub watches a shared TOPIC namespace (name = the topic
// pattern's ROOT segment, e.g. `events.*` → `events`). The watched
// name must be declared shared of the matching kind on the SAME
// group; a wildcard can't be statically resolved, so it's allowed
// (runtime match).
if t.shared() {
let kind = t.kind_str();
if !matches!(
t,
let (watched, coll_kind) = match 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('*');
| BundleTrigger::Docs { .. }
| BundleTrigger::Files { .. } => {
(t.collection_glob().unwrap_or_default(), t.kind_str())
}
BundleTrigger::Pubsub { topic_pattern, .. } => {
(topic_pattern.split('.').next().unwrap_or(""), "topic")
}
_ => {
return Err(ApplyError::Invalid(format!(
"a `shared` trigger must be a kv/docs/files/pubsub kind; \
`{}` has no shared collection store",
t.kind_str()
)));
}
};
let is_wildcard = watched.contains('*');
let declared = bundle
.collections
.iter()
.any(|c| c.kind == kind && c.name.eq_ignore_ascii_case(glob));
.any(|c| c.kind == coll_kind && c.name.eq_ignore_ascii_case(watched));
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`)"
"a `shared` trigger watches `{watched}`, which this group \
does not declare as a shared {coll_kind} collection (add \
it to `collections`)"
)));
}
}
@@ -3403,7 +3413,7 @@ fn current_trigger_identity(t: &Trigger, name_by_id: &HashMap<ScriptId, String>)
schedule, timezone, ..
} => Some(format!("cron|{script}|{schedule}|{timezone}")),
TriggerDetails::Pubsub { topic_pattern } => {
Some(format!("pubsub|{script}|{topic_pattern}|{sealed}"))
Some(format!("pubsub|{script}|{topic_pattern}|{sealed}|{shared}"))
}
TriggerDetails::Email { .. } => Some(format!("email|{script}")),
TriggerDetails::Queue { queue_name, .. } => Some(format!("queue|{queue_name}")),
@@ -4224,6 +4234,7 @@ mod tests {
dispatch_mode: None,
retry_max_attempts: None,
sealed: false,
shared: false,
})
.is_err());
assert!(validate_trigger_shape(&BundleTrigger::Pubsub {
@@ -4232,6 +4243,7 @@ mod tests {
dispatch_mode: None,
retry_max_attempts: None,
sealed: false,
shared: false,
})
.is_ok());
// Queue visibility floor matches the interactive API (>= 30): a value

View File

@@ -207,7 +207,9 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
topic_pattern: d.topic_pattern,
dispatch_mode,
retry_max_attempts,
// app-owned triggers are never sealed/shared (group-only).
sealed: false,
shared: false,
});
}
"queue" => {

View File

@@ -487,6 +487,11 @@ pub struct PubsubTriggerSpec {
/// See [`KvTriggerSpec::sealed`].
#[serde(default, skip_serializing_if = "is_false")]
pub sealed: bool,
/// §11.6 D2: `true` for a shared-TOPIC group trigger — fires when a subtree
/// app publishes to the group's declared shared topic, not on per-app
/// publishes. Group-only; requires a declared `kind = "topic"` collection.
#[serde(default, skip_serializing_if = "is_false")]
pub shared: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]