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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user