feat(routes): author group route templates in manifest + reconcile (§11 tail R4)
Open the declarative path so a [group] can declare route TEMPLATES.
- manifest.rs: drop the blanket "[group] cannot declare [[routes]]"
rejection (routes join the event trigger templates a group may own).
- apply_service:
- validate_bundle_for: remove the group routes rejection. A group
route's `script` is validated as binding a group-owned ENDPOINT via
`resolve_inherited_targets_for(Group)`, now extended to scan
bundle.routes as well as bundle.triggers — so a template binding the
group's own (declared or pre-existing) endpoint resolves, and one
pointing at a module or a missing name is a 422.
- insert_bundle_route takes a ScriptOwner (was app-only): a group node
writes a group_id route TEMPLATE, an app node an app-owned route.
- load_current(Group) loads the group's own routes via list_for_group
so re-apply is a NoOp and the template is prune-able.
Host-claim validation stays App-only (already guarded) — a template has
no single app; a descendant serves it on whatever host it claims (so
templates use host_kind = any in practice).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -580,9 +580,9 @@ impl ApplyService {
|
|||||||
) -> Result<HashMap<String, ScriptId>, ApplyError> {
|
) -> Result<HashMap<String, ScriptId>, ApplyError> {
|
||||||
match owner {
|
match owner {
|
||||||
ApplyOwner::App(app_id) => self.resolve_inherited_targets(app_id, bundle).await,
|
ApplyOwner::App(app_id) => self.resolve_inherited_targets(app_id, bundle).await,
|
||||||
// §11 tail: a group trigger TEMPLATE binds the group's OWN endpoint
|
// §11 tail: a group trigger or ROUTE TEMPLATE binds the group's OWN
|
||||||
// scripts (declared here or pre-existing). Surface the ones a
|
// endpoint scripts (declared here or pre-existing). Surface the ones
|
||||||
// template references but the bundle doesn't declare, so
|
// a template references but the bundle doesn't declare, so
|
||||||
// `validate_bundle` accepts them (the reconcile's name_to_id already
|
// `validate_bundle` accepts them (the reconcile's name_to_id already
|
||||||
// includes the group's current scripts).
|
// includes the group's current scripts).
|
||||||
ApplyOwner::Group(group_id) => {
|
ApplyOwner::Group(group_id) => {
|
||||||
@@ -595,6 +595,7 @@ impl ApplyService {
|
|||||||
.triggers
|
.triggers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| t.script().to_lowercase())
|
.map(|t| t.script().to_lowercase())
|
||||||
|
.chain(bundle.routes.iter().map(|r| r.script.to_lowercase()))
|
||||||
.collect();
|
.collect();
|
||||||
let by_name: HashMap<String, ScriptId> = self
|
let by_name: HashMap<String, ScriptId> = self
|
||||||
.scripts
|
.scripts
|
||||||
@@ -629,16 +630,13 @@ impl ApplyService {
|
|||||||
inherited_endpoints: &HashSet<String>,
|
inherited_endpoints: &HashSet<String>,
|
||||||
) -> Result<(), ApplyError> {
|
) -> Result<(), ApplyError> {
|
||||||
if matches!(owner, ApplyOwner::Group(_)) {
|
if matches!(owner, ApplyOwner::Group(_)) {
|
||||||
if !bundle.routes.is_empty() {
|
// §11 tail: a group MAY declare ROUTE TEMPLATES (validated as binding
|
||||||
return Err(ApplyError::Invalid(
|
// a group-owned endpoint by `validate_bundle` via the inherited
|
||||||
"a group manifest cannot declare routes — routes bind to an app".into(),
|
// 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
|
||||||
// §11 tail: a group MAY declare trigger TEMPLATES, but only for the
|
// per-app state (last_fired_at, the one-consumer lock, a sealed
|
||||||
// stateless EVENT kinds (kv/docs/files/pubsub) — those resolve live
|
// secret) and would need materialization; reject them, deferred.
|
||||||
// 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 on a group, deferred.
|
|
||||||
for t in &bundle.triggers {
|
for t in &bundle.triggers {
|
||||||
if !matches!(
|
if !matches!(
|
||||||
t,
|
t,
|
||||||
@@ -834,10 +832,10 @@ impl ApplyService {
|
|||||||
};
|
};
|
||||||
let br = bundle_routes[&ch.key];
|
let br = bundle_routes[&ch.key];
|
||||||
let sid = resolve_script(&name_to_id, &br.script)?;
|
let sid = resolve_script(&name_to_id, &br.script)?;
|
||||||
// Routes only exist on app nodes (a group bundle has none, so this
|
// §11 tail: an app node writes an app-owned route; a group node
|
||||||
// loop is empty for a group — validated above).
|
// writes a route TEMPLATE (group_id owner) that expands into every
|
||||||
let app_id = owner.app_id().expect("routes only exist on app nodes");
|
// descendant app's slice at rebuild.
|
||||||
insert_bundle_route(&mut *tx, app_id, sid, br).await?;
|
insert_bundle_route(&mut *tx, owner.as_script_owner(), sid, br).await?;
|
||||||
if is_update {
|
if is_update {
|
||||||
report.routes_updated += 1;
|
report.routes_updated += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -2175,8 +2173,8 @@ impl ApplyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn load_current(&self, owner: ApplyOwner) -> Result<CurrentState, ApplyError> {
|
async fn load_current(&self, owner: ApplyOwner) -> Result<CurrentState, ApplyError> {
|
||||||
// A group node has only scripts + vars; routes / triggers / secrets are
|
// A group node owns scripts + vars + route/trigger TEMPLATES (§11 tail);
|
||||||
// app-only and stay empty (the bundle for a group declares none).
|
// secrets stay empty (app-only — the bundle for a group declares none).
|
||||||
let (scripts, routes, triggers, secret_names, var_owner) = match owner {
|
let (scripts, routes, triggers, secret_names, var_owner) = match owner {
|
||||||
ApplyOwner::App(app_id) => (
|
ApplyOwner::App(app_id) => (
|
||||||
self.scripts
|
self.scripts
|
||||||
@@ -2199,9 +2197,12 @@ impl ApplyService {
|
|||||||
.list_for_group(group_id)
|
.list_for_group(group_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||||
Vec::new(),
|
// §11 tail: a group owns route + trigger TEMPLATES; load them so
|
||||||
// §11 tail: a group owns trigger TEMPLATES; load them so the
|
// the diff sees existing markers (NoOp on re-apply, prune-able).
|
||||||
// diff sees existing markers (NoOp on re-apply, prune-able).
|
self.routes
|
||||||
|
.list_for_group(group_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||||
self.triggers
|
self.triggers
|
||||||
.list_for_group(group_id)
|
.list_for_group(group_id)
|
||||||
.await
|
.await
|
||||||
@@ -3208,12 +3209,12 @@ fn resolve_script(
|
|||||||
|
|
||||||
async fn insert_bundle_route(
|
async fn insert_bundle_route(
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
app_id: AppId,
|
owner: ScriptOwner,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
br: &BundleRoute,
|
br: &BundleRoute,
|
||||||
) -> Result<(), ApplyError> {
|
) -> Result<(), ApplyError> {
|
||||||
let new = NewRoute {
|
let new = NewRoute {
|
||||||
owner: ScriptOwner::App(app_id),
|
owner,
|
||||||
script_id,
|
script_id,
|
||||||
host_kind: br.host_kind,
|
host_kind: br.host_kind,
|
||||||
host: br.host.clone(),
|
host: br.host.clone(),
|
||||||
|
|||||||
@@ -68,15 +68,11 @@ impl Manifest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A group node owns scripts + vars (+ secret names) and, as of §11 tail,
|
// A group node owns scripts + vars (+ secret names) and, as of §11 tail,
|
||||||
// EVENT trigger TEMPLATES (kv/docs/files/pubsub) that fan out live to
|
// ROUTE TEMPLATES + EVENT trigger TEMPLATES (kv/docs/files/pubsub) that
|
||||||
// descendant apps. Routes stay app-only; so do the stateful trigger
|
// fan out live to descendant apps. The stateful trigger kinds
|
||||||
// kinds (cron/queue/email need per-app state/secrets → materialization).
|
// (cron/queue/email) stay app-only — they need per-app state/secrets →
|
||||||
|
// materialization.
|
||||||
if m.group.is_some() {
|
if m.group.is_some() {
|
||||||
if !m.routes.is_empty() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"a [group] manifest cannot declare [[routes]] — routes bind to an app"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let t = &m.triggers;
|
let t = &m.triggers;
|
||||||
if !t.cron.is_empty() || !t.queue.is_empty() || !t.email.is_empty() {
|
if !t.cron.is_empty() || !t.queue.is_empty() || !t.email.is_empty() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
|
|||||||
Reference in New Issue
Block a user