feat(triggers): shared dead-letter trigger fan-out (B2)
A group declares a declaratively-authored [[triggers.dead_letter]] shared=true handler; when a message in the group's SHARED queue is exhausted, the dispatcher's q_terminal group branch (after persisting to group_dead_letters) fans out to it via list_matching_shared_dead_letter(owning_group, "queue", …), each outbox row stamped the WRITER app_id (the consuming app — the M2 shared -write model), so the handler runs under the consumer. The per-app list_matching_dead_letter gained AND t.shared = FALSE (the shared flag is the namespace boundary); the owning-group filter is the isolation boundary. Adds BundleTrigger::DeadLetter + a DeadLetterTriggerSpec manifest kind (group +shared only — validate_bundle_for rejects app-owned or non-shared, and exempts it from the shared-requires-a-collection rule); insert_trigger_tx now accepts dead_letter and writes dead_letter_trigger_details; current_trigger_identity matches a group-shared dead_letter so re-apply is a NoOp (app-owned ones stay diff-invisible). Pinned by tests/shared_dead_letter.rs (owning group matches, per-app query does not, foreign group does not). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -223,6 +223,9 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
for s in &t.queue {
|
||||
triggers.push(tagged("queue", s)?);
|
||||
}
|
||||
for s in &t.dead_letter {
|
||||
triggers.push(tagged("dead_letter", s)?);
|
||||
}
|
||||
|
||||
// Vars: key → JSON value. TOML values round-trip to JSON via serde so the
|
||||
// wire shape matches the server's `serde_json::Value`.
|
||||
|
||||
@@ -468,6 +468,8 @@ pub struct ManifestTriggers {
|
||||
pub email: Vec<EmailTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub queue: Vec<QueueTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dead_letter: Vec<DeadLetterTriggerSpec>,
|
||||
}
|
||||
|
||||
impl ManifestTriggers {
|
||||
@@ -480,6 +482,7 @@ impl ManifestTriggers {
|
||||
&& self.pubsub.is_empty()
|
||||
&& self.email.is_empty()
|
||||
&& self.queue.is_empty()
|
||||
&& self.dead_letter.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,6 +609,26 @@ pub struct QueueTriggerSpec {
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
/// §11.6 B2: a `[[triggers.dead_letter]]` handler. Declarative authoring is
|
||||
/// restricted to GROUP-owned + `shared = true` — it fires when a message in the
|
||||
/// group's SHARED queue is exhausted. `source_filter` narrows to a dead-letter
|
||||
/// source (`"queue"`); omitted matches any.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeadLetterTriggerSpec {
|
||||
pub script: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_filter: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// Must be `true` — a declarative dead_letter is a group-owned shared
|
||||
/// template. The server rejects a non-shared or app-owned one.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
/// `[secrets] names = [...]` — declares which secrets the app expects.
|
||||
/// Values are never in the manifest; `pic secret set` pushes them.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user