feat(stateful-templates): schema + dispatch guards + allow cron/queue on group (M5.1)
0062 adds `materialized_from` on triggers (a managed app-owned copy links back to its group template; CASCADE). The scheduler + queue-consumer queries gain `AND t.app_id IS NOT NULL` so a group-owned stateful TEMPLATE is never dispatched directly — only the per-descendant materialized app rows are. validate_bundle_for + manifest parse now allow cron/queue on a group (email stays rejected pending its per-app inbound-secret handling in M5.5). The materialization reconcile that expands templates into app rows lands in M5.2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -756,22 +756,16 @@ impl ApplyService {
|
|||||||
if matches!(owner, ApplyOwner::Group(_)) {
|
if matches!(owner, ApplyOwner::Group(_)) {
|
||||||
// §11 tail: a group MAY declare ROUTE TEMPLATES (validated as binding
|
// §11 tail: a group MAY declare ROUTE TEMPLATES (validated as binding
|
||||||
// a group-owned endpoint by `validate_bundle` via the inherited
|
// a group-owned endpoint by `validate_bundle` via the inherited
|
||||||
// targets) and TRIGGER TEMPLATES — but trigger templates are limited
|
// targets) and TRIGGER TEMPLATES. The stateless EVENT kinds
|
||||||
// to the stateless EVENT kinds (kv/docs/files/pubsub), which resolve
|
// (kv/docs/files/pubsub) resolve live via the chain union at
|
||||||
// live via the chain union at dispatch. cron/queue/email carry
|
// dispatch; §4.5 M5 the stateful CRON + QUEUE kinds are allowed and
|
||||||
// per-app state (last_fired_at, the one-consumer lock, a sealed
|
// MATERIALIZED into a per-descendant-app row. Only `email` (a per-app
|
||||||
// secret) and would need materialization; reject them, deferred.
|
// sealed inbound secret) is still rejected on a group.
|
||||||
for t in &bundle.triggers {
|
for t in &bundle.triggers {
|
||||||
if !matches!(
|
if matches!(t, BundleTrigger::Email { .. }) {
|
||||||
t,
|
|
||||||
BundleTrigger::Kv { .. }
|
|
||||||
| BundleTrigger::Docs { .. }
|
|
||||||
| BundleTrigger::Files { .. }
|
|
||||||
| BundleTrigger::Pubsub { .. }
|
|
||||||
) {
|
|
||||||
return Err(ApplyError::Invalid(
|
return Err(ApplyError::Invalid(
|
||||||
"a group trigger template must be an event kind \
|
"a group email trigger template is not yet supported \
|
||||||
(kv, docs, files, pubsub); cron/queue/email are app-only"
|
(per-app inbound secret); cron/queue/event kinds are allowed"
|
||||||
.into(),
|
.into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ async fn tick(pool: &PgPool, now: DateTime<Utc>) -> Result<usize, sqlx::Error> {
|
|||||||
d.schedule, d.timezone, d.last_fired_at \
|
d.schedule, d.timezone, d.last_fired_at \
|
||||||
FROM cron_trigger_details d \
|
FROM cron_trigger_details d \
|
||||||
JOIN triggers t ON t.id = d.trigger_id \
|
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",
|
FOR UPDATE OF d SKIP LOCKED",
|
||||||
)
|
)
|
||||||
.fetch_all(&mut *tx)
|
.fetch_all(&mut *tx)
|
||||||
|
|||||||
@@ -1769,7 +1769,8 @@ impl TriggerRepo for PostgresTriggerRepo {
|
|||||||
FROM triggers t \
|
FROM triggers t \
|
||||||
JOIN queue_trigger_details d ON d.trigger_id = t.id \
|
JOIN queue_trigger_details d ON d.trigger_id = t.id \
|
||||||
JOIN scripts s ON s.id = t.script_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)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -410,6 +410,7 @@ table: triggers
|
|||||||
group_id: uuid NULL
|
group_id: uuid NULL
|
||||||
sealed: boolean NOT NULL default=false
|
sealed: boolean NOT NULL default=false
|
||||||
shared: boolean NOT NULL default=false
|
shared: boolean NOT NULL default=false
|
||||||
|
materialized_from: uuid NULL
|
||||||
|
|
||||||
table: vars
|
table: vars
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
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_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_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_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_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_group_name_uniq: public.triggers USING btree (group_id, name) WHERE (group_id IS NOT NULL)
|
||||||
triggers_pkey: public.triggers USING btree (id)
|
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])))
|
[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_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_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_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
|
[FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] triggers_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] triggers_pkey: PRIMARY KEY (id)
|
||||||
@@ -942,3 +945,4 @@ constraints on vars:
|
|||||||
0059: sealed templates
|
0059: sealed templates
|
||||||
0060: group suppressions
|
0060: group suppressions
|
||||||
0061: shared triggers
|
0061: shared triggers
|
||||||
|
0062: materialized triggers
|
||||||
|
|||||||
@@ -78,14 +78,15 @@ impl Manifest {
|
|||||||
// ancestor, for its whole subtree. The stateful trigger kinds
|
// ancestor, for its whole subtree. The stateful trigger kinds
|
||||||
// (cron/queue/email) stay app-only — they need per-app state/secrets →
|
// (cron/queue/email) stay app-only — they need per-app state/secrets →
|
||||||
// materialization.
|
// materialization.
|
||||||
if m.group.is_some() {
|
// §4.5 M5: a group may declare STATEFUL trigger templates too — cron and
|
||||||
let t = &m.triggers;
|
// queue materialize a per-descendant-app row (email lands with its
|
||||||
if !t.cron.is_empty() || !t.queue.is_empty() || !t.email.is_empty() {
|
// per-app inbound-secret handling). Only the app-specific `email` kind
|
||||||
anyhow::bail!(
|
// is still rejected on a group here.
|
||||||
"a [group] trigger template must be an event kind \
|
if m.group.is_some() && !m.triggers.email.is_empty() {
|
||||||
(kv, docs, files, pubsub); cron/queue/email are app-only"
|
anyhow::bail!(
|
||||||
);
|
"a [group] email trigger template is not yet supported \
|
||||||
}
|
(per-app inbound secret); cron/queue/event kinds are allowed"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(m)
|
Ok(m)
|
||||||
}
|
}
|
||||||
@@ -885,13 +886,20 @@ mod tests {
|
|||||||
assert!(routed.is_group());
|
assert!(routed.is_group());
|
||||||
assert_eq!(routed.routes.len(), 1);
|
assert_eq!(routed.routes.len(), 1);
|
||||||
|
|
||||||
// ...but a STATEFUL trigger kind (cron/queue/email) on a group is rejected.
|
// §4.5 M5: a group cron template is now ALLOWED (materialized per app).
|
||||||
let err = Manifest::parse(
|
let cron = Manifest::parse(
|
||||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||||
[[triggers.cron]]\nscript = \"shared\"\nschedule = \"0 0 * * * *\"\n",
|
[[triggers.cron]]\nscript = \"shared\"\nschedule = \"0 0 * * * *\"\n",
|
||||||
)
|
)
|
||||||
.expect_err("group with a cron trigger is rejected");
|
.expect("group cron template parses");
|
||||||
assert!(err.to_string().contains("event kind"), "got: {err}");
|
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.
|
// Neither / both is rejected.
|
||||||
Manifest::parse("[vars]\nx = 1\n").expect_err("no [app] or [group]");
|
Manifest::parse("[vars]\nx = 1\n").expect_err("no [app] or [group]");
|
||||||
|
|||||||
Reference in New Issue
Block a user