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> {
|
||||
match owner {
|
||||
ApplyOwner::App(app_id) => self.resolve_inherited_targets(app_id, bundle).await,
|
||||
// §11 tail: a group trigger TEMPLATE binds the group's OWN endpoint
|
||||
// scripts (declared here or pre-existing). Surface the ones a
|
||||
// template references but the bundle doesn't declare, so
|
||||
// §11 tail: a group trigger or ROUTE TEMPLATE binds the group's OWN
|
||||
// endpoint scripts (declared here or pre-existing). Surface the ones
|
||||
// a template references but the bundle doesn't declare, so
|
||||
// `validate_bundle` accepts them (the reconcile's name_to_id already
|
||||
// includes the group's current scripts).
|
||||
ApplyOwner::Group(group_id) => {
|
||||
@@ -595,6 +595,7 @@ impl ApplyService {
|
||||
.triggers
|
||||
.iter()
|
||||
.map(|t| t.script().to_lowercase())
|
||||
.chain(bundle.routes.iter().map(|r| r.script.to_lowercase()))
|
||||
.collect();
|
||||
let by_name: HashMap<String, ScriptId> = self
|
||||
.scripts
|
||||
@@ -629,16 +630,13 @@ impl ApplyService {
|
||||
inherited_endpoints: &HashSet<String>,
|
||||
) -> Result<(), ApplyError> {
|
||||
if matches!(owner, ApplyOwner::Group(_)) {
|
||||
if !bundle.routes.is_empty() {
|
||||
return Err(ApplyError::Invalid(
|
||||
"a group manifest cannot declare routes — routes bind to an app".into(),
|
||||
));
|
||||
}
|
||||
// §11 tail: a group MAY declare trigger TEMPLATES, but only for the
|
||||
// stateless EVENT kinds (kv/docs/files/pubsub) — those 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 on a group, deferred.
|
||||
// §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.
|
||||
for t in &bundle.triggers {
|
||||
if !matches!(
|
||||
t,
|
||||
@@ -834,10 +832,10 @@ impl ApplyService {
|
||||
};
|
||||
let br = bundle_routes[&ch.key];
|
||||
let sid = resolve_script(&name_to_id, &br.script)?;
|
||||
// Routes only exist on app nodes (a group bundle has none, so this
|
||||
// loop is empty for a group — validated above).
|
||||
let app_id = owner.app_id().expect("routes only exist on app nodes");
|
||||
insert_bundle_route(&mut *tx, app_id, sid, br).await?;
|
||||
// §11 tail: an app node writes an app-owned route; a group node
|
||||
// writes a route TEMPLATE (group_id owner) that expands into every
|
||||
// descendant app's slice at rebuild.
|
||||
insert_bundle_route(&mut *tx, owner.as_script_owner(), sid, br).await?;
|
||||
if is_update {
|
||||
report.routes_updated += 1;
|
||||
} else {
|
||||
@@ -2175,8 +2173,8 @@ impl ApplyService {
|
||||
}
|
||||
|
||||
async fn load_current(&self, owner: ApplyOwner) -> Result<CurrentState, ApplyError> {
|
||||
// A group node has only scripts + vars; routes / triggers / secrets are
|
||||
// app-only and stay empty (the bundle for a group declares none).
|
||||
// A group node owns scripts + vars + route/trigger TEMPLATES (§11 tail);
|
||||
// secrets stay empty (app-only — the bundle for a group declares none).
|
||||
let (scripts, routes, triggers, secret_names, var_owner) = match owner {
|
||||
ApplyOwner::App(app_id) => (
|
||||
self.scripts
|
||||
@@ -2199,9 +2197,12 @@ impl ApplyService {
|
||||
.list_for_group(group_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||
Vec::new(),
|
||||
// §11 tail: a group owns trigger TEMPLATES; load them so the
|
||||
// diff sees existing markers (NoOp on re-apply, prune-able).
|
||||
// §11 tail: a group owns route + trigger TEMPLATES; load them so
|
||||
// the 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
|
||||
.list_for_group(group_id)
|
||||
.await
|
||||
@@ -3208,12 +3209,12 @@ fn resolve_script(
|
||||
|
||||
async fn insert_bundle_route(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
app_id: AppId,
|
||||
owner: ScriptOwner,
|
||||
script_id: ScriptId,
|
||||
br: &BundleRoute,
|
||||
) -> Result<(), ApplyError> {
|
||||
let new = NewRoute {
|
||||
owner: ScriptOwner::App(app_id),
|
||||
owner,
|
||||
script_id,
|
||||
host_kind: br.host_kind,
|
||||
host: br.host.clone(),
|
||||
|
||||
Reference in New Issue
Block a user