diff --git a/crates/manager-core/migrations/0062_materialized_triggers.sql b/crates/manager-core/migrations/0062_materialized_triggers.sql new file mode 100644 index 0000000..938caa5 --- /dev/null +++ b/crates/manager-core/migrations/0062_materialized_triggers.sql @@ -0,0 +1,25 @@ +-- §4.5 M5: STATEFUL group trigger templates (cron / queue / email) via +-- per-descendant MATERIALIZATION. +-- +-- The event kinds (kv/docs/files/pubsub) resolve a group template LIVE at +-- dispatch via the chain CTE — no per-app rows. The stateful kinds can't: +-- * cron needs a per-app `last_fired_at` the scheduler advances; +-- * queue needs the one-consumer-per-(app_id, queue_name) advisory lock; +-- * email needs a per-app SEALED inbound secret. +-- So a group stateful template is instead MATERIALIZED into an app-owned +-- trigger row per descendant app (created/removed as the tree changes), and the +-- unchanged dispatch paths (scheduler, queue consumer, email webhook) only ever +-- see app-owned rows. +-- +-- `materialized_from` links a managed app-owned row back to the group template +-- it was expanded from: NULL = a hand-authored trigger; set = a reconciler-owned +-- copy (dropped + re-created as descendants come and go). ON DELETE CASCADE so +-- deleting the group template (RESTRICT-guarded elsewhere) or the group cleans +-- up the copies; a plain app delete CASCADEs the row away via `triggers.app_id`. + +ALTER TABLE triggers + ADD COLUMN materialized_from UUID REFERENCES triggers(id) ON DELETE CASCADE; + +-- The reconciler looks up existing copies by their source template. +CREATE INDEX idx_triggers_materialized_from + ON triggers (materialized_from) WHERE materialized_from IS NOT NULL; diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 6df57c8..cb8f11d 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -756,22 +756,16 @@ impl ApplyService { if matches!(owner, ApplyOwner::Group(_)) { // §11 tail: a group MAY declare ROUTE TEMPLATES (validated as binding // a group-owned endpoint by `validate_bundle` via the inherited - // targets) and TRIGGER TEMPLATES — but trigger templates are limited - // to the stateless EVENT kinds (kv/docs/files/pubsub), which resolve - // live via the chain union at dispatch. cron/queue/email carry - // per-app state (last_fired_at, the one-consumer lock, a sealed - // secret) and would need materialization; reject them, deferred. + // targets) and TRIGGER TEMPLATES. The stateless EVENT kinds + // (kv/docs/files/pubsub) resolve live via the chain union at + // dispatch; §4.5 M5 the stateful CRON + QUEUE kinds are allowed and + // MATERIALIZED into a per-descendant-app row. Only `email` (a per-app + // sealed inbound secret) is still rejected on a group. for t in &bundle.triggers { - if !matches!( - t, - BundleTrigger::Kv { .. } - | BundleTrigger::Docs { .. } - | BundleTrigger::Files { .. } - | BundleTrigger::Pubsub { .. } - ) { + if matches!(t, BundleTrigger::Email { .. }) { return Err(ApplyError::Invalid( - "a group trigger template must be an event kind \ - (kv, docs, files, pubsub); cron/queue/email are app-only" + "a group email trigger template is not yet supported \ + (per-app inbound secret); cron/queue/event kinds are allowed" .into(), )); } diff --git a/crates/manager-core/src/cron_scheduler.rs b/crates/manager-core/src/cron_scheduler.rs index f4c4426..b1736ce 100644 --- a/crates/manager-core/src/cron_scheduler.rs +++ b/crates/manager-core/src/cron_scheduler.rs @@ -119,7 +119,7 @@ async fn tick(pool: &PgPool, now: DateTime) -> Result { d.schedule, d.timezone, d.last_fired_at \ FROM cron_trigger_details d \ JOIN triggers t ON t.id = d.trigger_id \ - WHERE t.enabled = TRUE \ + WHERE t.enabled = TRUE AND t.app_id IS NOT NULL \ FOR UPDATE OF d SKIP LOCKED", ) .fetch_all(&mut *tx) diff --git a/crates/manager-core/src/trigger_repo.rs b/crates/manager-core/src/trigger_repo.rs index 101be5f..eb896aa 100644 --- a/crates/manager-core/src/trigger_repo.rs +++ b/crates/manager-core/src/trigger_repo.rs @@ -1769,7 +1769,8 @@ impl TriggerRepo for PostgresTriggerRepo { FROM triggers t \ JOIN queue_trigger_details d ON d.trigger_id = t.id \ JOIN scripts s ON s.id = t.script_id \ - WHERE t.kind = 'queue' AND t.enabled = TRUE AND s.enabled = TRUE", + WHERE t.kind = 'queue' AND t.enabled = TRUE AND s.enabled = TRUE \ + AND t.app_id IS NOT NULL", ) .fetch_all(&self.pool) .await?; diff --git a/crates/manager-core/tests/expected_schema.txt b/crates/manager-core/tests/expected_schema.txt index 1f9774f..f1dfc38 100644 --- a/crates/manager-core/tests/expected_schema.txt +++ b/crates/manager-core/tests/expected_schema.txt @@ -410,6 +410,7 @@ table: triggers group_id: uuid NULL sealed: boolean NOT NULL default=false shared: boolean NOT NULL default=false + materialized_from: uuid NULL table: vars id: uuid NOT NULL default=gen_random_uuid() @@ -631,6 +632,7 @@ indexes on triggers: idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text)) idx_triggers_group_kind_enabled: public.triggers USING btree (group_id, kind) WHERE ((enabled = true) AND (group_id IS NOT NULL)) idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true) + idx_triggers_materialized_from: public.triggers USING btree (materialized_from) WHERE (materialized_from IS NOT NULL) triggers_app_name_uniq: public.triggers USING btree (app_id, name) WHERE (app_id IS NOT NULL) triggers_group_name_uniq: public.triggers USING btree (group_id, name) WHERE (group_id IS NOT NULL) triggers_pkey: public.triggers USING btree (id) @@ -870,6 +872,7 @@ constraints on triggers: [CHECK] triggers_retry_backoff_check: CHECK ((retry_backoff = ANY (ARRAY['exponential'::text, 'linear'::text, 'constant'::text]))) [FOREIGN KEY] triggers_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE [FOREIGN KEY] triggers_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT + [FOREIGN KEY] triggers_materialized_from_fkey: FOREIGN KEY (materialized_from) REFERENCES triggers(id) ON DELETE CASCADE [FOREIGN KEY] triggers_registered_by_principal_fkey: FOREIGN KEY (registered_by_principal) REFERENCES admin_users(id) ON DELETE CASCADE [FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE [PRIMARY KEY] triggers_pkey: PRIMARY KEY (id) @@ -942,3 +945,4 @@ constraints on vars: 0059: sealed templates 0060: group suppressions 0061: shared triggers + 0062: materialized triggers diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index 9fcd645..48d3803 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -78,14 +78,15 @@ impl Manifest { // ancestor, for its whole subtree. The stateful trigger kinds // (cron/queue/email) stay app-only — they need per-app state/secrets → // materialization. - if m.group.is_some() { - let t = &m.triggers; - if !t.cron.is_empty() || !t.queue.is_empty() || !t.email.is_empty() { - anyhow::bail!( - "a [group] trigger template must be an event kind \ - (kv, docs, files, pubsub); cron/queue/email are app-only" - ); - } + // §4.5 M5: a group may declare STATEFUL trigger templates too — cron and + // queue materialize a per-descendant-app row (email lands with its + // per-app inbound-secret handling). Only the app-specific `email` kind + // is still rejected on a group here. + if m.group.is_some() && !m.triggers.email.is_empty() { + anyhow::bail!( + "a [group] email trigger template is not yet supported \ + (per-app inbound secret); cron/queue/event kinds are allowed" + ); } Ok(m) } @@ -885,13 +886,20 @@ mod tests { assert!(routed.is_group()); assert_eq!(routed.routes.len(), 1); - // ...but a STATEFUL trigger kind (cron/queue/email) on a group is rejected. - let err = Manifest::parse( + // §4.5 M5: a group cron template is now ALLOWED (materialized per app). + let cron = Manifest::parse( "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\ [[triggers.cron]]\nscript = \"shared\"\nschedule = \"0 0 * * * *\"\n", ) - .expect_err("group with a cron trigger is rejected"); - assert!(err.to_string().contains("event kind"), "got: {err}"); + .expect("group cron template parses"); + assert_eq!(cron.triggers.cron.len(), 1); + // ...but an email template on a group is still rejected (per-app secret). + let err = Manifest::parse( + "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\ + [[triggers.email]]\nscript = \"shared\"\ninbound_secret_ref = \"sig\"\n", + ) + .expect_err("group email template is rejected"); + assert!(err.to_string().contains("email"), "got: {err}"); // Neither / both is rejected. Manifest::parse("[vars]\nx = 1\n").expect_err("no [app] or [group]");