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:
MechaCat02
2026-07-01 20:55:49 +02:00
parent 0210ace406
commit f04eaed0b7
5 changed files with 151 additions and 14 deletions

View File

@@ -67,6 +67,10 @@ pub struct Trigger {
/// so toggling it re-materializes the row.
#[serde(default)]
pub sealed: bool,
/// §11.6: `true` for a shared-collection group trigger (watches a shared
/// collection, not per-app ones). Also part of the apply diff identity.
#[serde(default)]
pub shared: bool,
pub dispatch_mode: TriggerDispatchMode,
pub retry_max_attempts: u32,
pub retry_backoff: BackoffShape,
@@ -623,6 +627,9 @@ pub(crate) async fn insert_trigger_tx(
// §11 tail: `true` for a sealed (non-suppressible) group trigger template.
// Always `false` for an app-owned trigger.
sealed: bool,
// §11.6: `true` for a shared-collection group trigger (watches a shared
// collection, not per-app ones). Always `false` for an app-owned trigger.
shared: bool,
details: &TriggerDetails,
) -> Result<TriggerId, TriggerRepoError> {
let kind = match details {
@@ -675,8 +682,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, sealed \
) VALUES ($1, $2, $3, $4, TRUE, $5, $6, $7, $8, $9, $10) RETURNING id",
registered_by_principal, sealed, shared \
) VALUES ($1, $2, $3, $4, TRUE, $5, $6, $7, $8, $9, $10, $11) RETURNING id",
)
.bind(owner_app_id)
.bind(owner_group_id)
@@ -688,6 +695,7 @@ pub(crate) async fn insert_trigger_tx(
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
.bind(registered_by.into_inner())
.bind(sealed)
.bind(shared)
.fetch_one(&mut **tx)
.await?;
let tid = row.0;
@@ -888,6 +896,7 @@ impl TriggerRepo for PostgresTriggerRepo {
kind: TriggerKind::Kv,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
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)
@@ -956,6 +965,7 @@ impl TriggerRepo for PostgresTriggerRepo {
kind: TriggerKind::Docs,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
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)
@@ -1019,6 +1029,7 @@ impl TriggerRepo for PostgresTriggerRepo {
kind: TriggerKind::DeadLetter,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
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)
@@ -1088,6 +1099,7 @@ impl TriggerRepo for PostgresTriggerRepo {
kind: TriggerKind::Cron,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
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)
@@ -1157,6 +1169,7 @@ impl TriggerRepo for PostgresTriggerRepo {
kind: TriggerKind::Files,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
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)
@@ -1221,6 +1234,7 @@ impl TriggerRepo for PostgresTriggerRepo {
kind: TriggerKind::Pubsub,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
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)
@@ -1283,6 +1297,7 @@ impl TriggerRepo for PostgresTriggerRepo {
kind: TriggerKind::Email,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
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)
@@ -1323,7 +1338,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, sealed, dispatch_mode, \
"SELECT id, app_id, script_id, name, kind, enabled, sealed, shared, 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",
@@ -1351,7 +1366,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, sealed, dispatch_mode, \
"SELECT id, app_id, group_id, script_id, name, kind, enabled, sealed, shared, 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",
@@ -1377,7 +1392,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, sealed, dispatch_mode, \
"SELECT id, app_id, group_id, script_id, name, kind, enabled, sealed, shared, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at \
FROM triggers WHERE id = $1",
@@ -1726,6 +1741,7 @@ impl TriggerRepo for PostgresTriggerRepo {
kind: TriggerKind::Queue,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
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)
@@ -1936,6 +1952,7 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
kind,
enabled: parent.enabled,
sealed: parent.sealed,
shared: parent.shared,
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)
@@ -1989,6 +2006,9 @@ struct TriggerRow {
// queries that must see a true value SELECT it explicitly.
#[sqlx(default)]
sealed: bool,
// §11.6: same `default` treatment as `sealed`.
#[sqlx(default)]
shared: bool,
dispatch_mode: String,
retry_max_attempts: i32,
retry_backoff: String,