feat(routes): owner-polymorphic Route + list_effective expansion query (§11 tail R2)

Make the route layer owner-aware ahead of the live rebuild. `Route.app_id`
becomes Option + a `group_id` (mirrors `Trigger`); `NewRoute` carries a
`ScriptOwner` so the interactive API passes App and the reconcile can pass
a group. `insert_route_tx` writes both owner columns; `RouteRow` selects
`group_id` everywhere (the interactive delete/list 404 a group template,
which is apply-managed, not app-addressable).

Adds the two repo methods the live rebuild + apply need:
- `list_for_group` — a group's own route templates (apply diff, ls).
- `list_effective` — the all-apps generalization of CHAIN_LEVELS_CTE: for
  every app, walk its ancestor chain and join routes owned at each level,
  tagging each with the effective (firing) app + the owner's chain depth.
  Runs only on a RouteTable rebuild, never per request; validated with
  EXPLAIN against the live schema.

`compile_route` now takes the effective app_id explicitly (reused next
commit for the per-app expansion); `compile_routes` skips group templates
(no single app) — they materialize via the effective path. Runtime
behavior is unchanged this commit: the table still rebuilds from
`list_all`, and no group routes can exist yet (authoring lands in R4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-30 21:39:07 +02:00
parent 0a583aa467
commit 469e8526d7
5 changed files with 180 additions and 40 deletions

View File

@@ -13,7 +13,7 @@ use axum::{
Extension, Json, Router,
};
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId};
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId, ScriptOwner};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -230,7 +230,7 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
let created = state
.routes
.create(NewRoute {
app_id,
owner: ScriptOwner::App(app_id),
script_id,
host_kind: input.host_kind,
host: input.host,
@@ -259,10 +259,14 @@ async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
.get(route_id)
.await?
.ok_or(RouteApiError::RouteNotFound(route_id))?;
// §11 tail: the interactive route API is app-scoped. A group-owned route
// TEMPLATE (`app_id` None) is managed declaratively via apply, not
// addressable here — treat it as not found.
let route_app_id = route.app_id.ok_or(RouteApiError::RouteNotFound(route_id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteRoute(route.app_id),
Capability::AppWriteRoute(route_app_id),
)
.await?;
state.routes.delete(route_id).await?;
@@ -409,27 +413,41 @@ pub fn compile_routes(rows: &[Route]) -> Vec<CompiledRoute> {
// A disabled route (§4.3) is dropped from the match table entirely, so
// a request to it 404s indistinguishably from an absent route.
.filter(|r| r.enabled)
.filter_map(|r| match compile_route(r) {
Ok(compiled) => Some(compiled),
Err(e) => {
tracing::warn!(
route_id = %r.id,
app_id = %r.app_id,
path = %r.path,
error = %e,
"skipping un-compilable stored route — it will not match; \
delete or fix it"
);
None
}
.filter_map(|r| {
// A group-owned route TEMPLATE (`app_id` None) has no single app to
// compile into — it is materialized per descendant by the effective
// rebuild (`compile_effective_routes`), not this app-only path.
let app_id = r.app_id?;
compile_one(r, app_id)
})
.collect()
}
fn compile_route(r: &Route) -> Result<CompiledRoute, pattern::ParseError> {
/// Parse one stored route into a `CompiledRoute` bound to `app_id` (the
/// effective app — its own for app routes, the firing descendant for a group
/// template). Lenient: an un-compilable row is skipped-with-warning, never an
/// error (it must not take down the data plane or fail an unrelated rebuild).
fn compile_one(r: &Route, app_id: AppId) -> Option<CompiledRoute> {
match compile_route(r, app_id) {
Ok(compiled) => Some(compiled),
Err(e) => {
tracing::warn!(
route_id = %r.id,
app_id = %app_id,
path = %r.path,
error = %e,
"skipping un-compilable stored route — it will not match; \
delete or fix it"
);
None
}
}
}
fn compile_route(r: &Route, app_id: AppId) -> Result<CompiledRoute, pattern::ParseError> {
Ok(CompiledRoute {
route_id: r.id,
app_id: r.app_id,
app_id,
script_id: r.script_id,
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
path: pattern::parse_path(r.path_kind, &r.path)?,
@@ -631,7 +649,8 @@ mod tests {
fn route_with_path(path: &str) -> Route {
Route {
id: Uuid::new_v4(),
app_id: AppId::from(Uuid::new_v4()),
app_id: Some(AppId::from(Uuid::new_v4())),
group_id: None,
script_id: ScriptId::from(Uuid::new_v4()),
host_kind: HostKind::Any,
host: String::new(),