diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index d3cd790..b803c4b 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -234,6 +234,11 @@ pub enum BundleTrigger { retry_max_attempts: Option, #[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) 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 diff --git a/crates/picloud-cli/src/cmds/pull.rs b/crates/picloud-cli/src/cmds/pull.rs index b5ef466..2d84436 100644 --- a/crates/picloud-cli/src/cmds/pull.rs +++ b/crates/picloud-cli/src/cmds/pull.rs @@ -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" => { diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index f9ae577..9a23e34 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -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)]