From 469e8526d717e8f8feafe5136187a97b31755ac3 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 30 Jun 2026 21:39:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(routes):=20owner-polymorphic=20Route=20+?= =?UTF-8?q?=20list=5Feffective=20expansion=20query=20(=C2=A711=20tail=20R2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/manager-core/src/app_bootstrap.rs | 4 +- crates/manager-core/src/apply_service.rs | 5 +- crates/manager-core/src/route_admin.rs | 57 ++++++--- crates/manager-core/src/route_repo.rs | 140 +++++++++++++++++++++-- crates/shared/src/route.rs | 14 ++- 5 files changed, 180 insertions(+), 40 deletions(-) diff --git a/crates/manager-core/src/app_bootstrap.rs b/crates/manager-core/src/app_bootstrap.rs index 4a2df42..9f4778a 100644 --- a/crates/manager-core/src/app_bootstrap.rs +++ b/crates/manager-core/src/app_bootstrap.rs @@ -6,7 +6,7 @@ use std::sync::Arc; -use picloud_shared::{App, AppId, HostKind, PathKind}; +use picloud_shared::{App, AppId, HostKind, PathKind, ScriptOwner}; use crate::app_repo::AppRepository; use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError}; @@ -76,7 +76,7 @@ async fn seed_into( routes .create(NewRoute { - app_id: default.id, + owner: ScriptOwner::App(default.id), script_id: script.id, host_kind: HostKind::Any, host: String::new(), diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index e8b92eb..91c608f 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -3216,7 +3216,7 @@ async fn insert_bundle_route( br: &BundleRoute, ) -> Result<(), ApplyError> { let new = NewRoute { - app_id, + owner: ScriptOwner::App(app_id), script_id, host_kind: br.host_kind, host: br.host.clone(), @@ -3553,7 +3553,8 @@ mod tests { let sid = s.id; let route = Route { id: uuid::Uuid::new_v4(), - app_id: AppId::from(uuid::Uuid::nil()), + app_id: Some(AppId::from(uuid::Uuid::nil())), + group_id: None, script_id: sid, host_kind: HostKind::Any, host: String::new(), diff --git a/crates/manager-core/src/route_admin.rs b/crates/manager-core/src/route_admin.rs index b7f36f3..1c84228 100644 --- a/crates/manager-core/src/route_admin.rs +++ b/crates/manager-core/src/route_admin.rs @@ -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( 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( .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 { // 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 { +/// 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 { + 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 { 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(), diff --git a/crates/manager-core/src/route_repo.rs b/crates/manager-core/src/route_repo.rs index e5ea465..22b5a5c 100644 --- a/crates/manager-core/src/route_repo.rs +++ b/crates/manager-core/src/route_repo.rs @@ -4,7 +4,9 @@ //! after every write — see the route_admin module for the binding. use async_trait::async_trait; -use picloud_shared::{AppId, DispatchMode, HostKind, PathKind, Route, ScriptId}; +use picloud_shared::{ + AppId, DispatchMode, GroupId, HostKind, PathKind, Route, ScriptId, ScriptOwner, +}; use sqlx::PgPool; use uuid::Uuid; @@ -12,7 +14,10 @@ use crate::repo::ScriptRepositoryError; #[derive(Debug, Clone)] pub struct NewRoute { - pub app_id: AppId, + /// §11 tail: an **app** owns a concrete route; a **group** owns a route + /// TEMPLATE. The interactive route API always passes `App`; the declarative + /// reconcile passes the bundle node's owner. + pub owner: ScriptOwner, pub script_id: ScriptId, pub host_kind: HostKind, pub host: String, @@ -25,6 +30,18 @@ pub struct NewRoute { pub enabled: bool, } +/// A route resolved for a specific app via the §11 tail expansion: the route +/// row itself plus the **effective app** it applies to (the firing +/// descendant) and the chain `depth` at which its owner sits (0 = the app's +/// own route, ≥1 = an ancestor-group template). Used only by the in-memory +/// RouteTable rebuild — never on the request hot path. +#[derive(Debug, Clone)] +pub struct EffectiveRoute { + pub effective_app_id: AppId, + pub depth: i32, + pub route: Route, +} + #[async_trait] pub trait RouteRepository: Send + Sync { async fn list_all(&self) -> Result, ScriptRepositoryError>; @@ -33,6 +50,36 @@ pub trait RouteRepository: Send + Sync { /// (not a path param). async fn get(&self, route_id: Uuid) -> Result, ScriptRepositoryError>; async fn list_for_app(&self, app_id: AppId) -> Result, ScriptRepositoryError>; + /// Group-owned route TEMPLATES (§11 tail) declared directly at `group_id`. + /// Backs the declarative apply diff for a `[group]` node and + /// `pic routes ls --group`. Defaults to empty so non-Postgres impls + /// (tests) need not provide it. + async fn list_for_group( + &self, + _group_id: GroupId, + ) -> Result, ScriptRepositoryError> { + Ok(Vec::new()) + } + /// §11 tail: every route resolved for every app — each app's own routes + /// PLUS its ancestor-group templates, tagged with the effective app and + /// the owner's chain depth. The in-memory RouteTable rebuild consumes this + /// (expansion happens here, once per rebuild, never per request). Defaults + /// to `list_all` mapped at depth 0 so non-Postgres impls degrade to the + /// pre-§11 (app-only) behavior. + async fn list_effective(&self) -> Result, ScriptRepositoryError> { + Ok(self + .list_all() + .await? + .into_iter() + .filter_map(|route| { + route.app_id.map(|a| EffectiveRoute { + effective_app_id: a, + depth: 0, + route, + }) + }) + .collect()) + } async fn list_for_script( &self, script_id: ScriptId, @@ -64,7 +111,7 @@ impl PostgresRouteRepository { impl RouteRepository for PostgresRouteRepository { async fn list_all(&self) -> Result, ScriptRepositoryError> { let rows = sqlx::query_as::<_, RouteRow>( - "SELECT id, app_id, script_id, host_kind, host, host_param_name, \ + "SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \ path_kind, path, method, dispatch_mode, enabled, created_at \ FROM routes ORDER BY created_at", ) @@ -75,7 +122,7 @@ impl RouteRepository for PostgresRouteRepository { async fn get(&self, route_id: Uuid) -> Result, ScriptRepositoryError> { let row = sqlx::query_as::<_, RouteRow>( - "SELECT id, app_id, script_id, host_kind, host, host_param_name, \ + "SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \ path_kind, path, method, dispatch_mode, enabled, created_at \ FROM routes WHERE id = $1", ) @@ -87,7 +134,7 @@ impl RouteRepository for PostgresRouteRepository { async fn list_for_app(&self, app_id: AppId) -> Result, ScriptRepositoryError> { let rows = sqlx::query_as::<_, RouteRow>( - "SELECT id, app_id, script_id, host_kind, host, host_param_name, \ + "SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \ path_kind, path, method, dispatch_mode, enabled, created_at \ FROM routes WHERE app_id = $1 ORDER BY created_at", ) @@ -97,12 +144,60 @@ impl RouteRepository for PostgresRouteRepository { Ok(rows.into_iter().map(Into::into).collect()) } + async fn list_for_group(&self, group_id: GroupId) -> Result, ScriptRepositoryError> { + let rows = sqlx::query_as::<_, RouteRow>( + "SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \ + path_kind, path, method, dispatch_mode, enabled, created_at \ + FROM routes WHERE group_id = $1 ORDER BY created_at", + ) + .bind(group_id.into_inner()) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(Into::into).collect()) + } + + async fn list_effective(&self) -> Result, ScriptRepositoryError> { + // The all-apps generalization of CHAIN_LEVELS_CTE: for EVERY app, walk + // its ancestor-group chain, then join routes owned at each level. A + // group template appears once per descendant app (tagged with that + // app + the owner's depth); an app's own route appears at depth 0. + // Runs only on a RouteTable rebuild, never per request. + let rows = sqlx::query_as::<_, EffectiveRouteRow>( + "WITH RECURSIVE app_chain AS ( \ + SELECT a.id AS effective_app_id, a.id AS owner_app, \ + NULL::uuid AS owner_group, a.group_id AS next_group, 0 AS depth \ + FROM apps a \ + UNION ALL \ + SELECT ac.effective_app_id, NULL::uuid, g.id, g.parent_id, ac.depth + 1 \ + FROM groups g JOIN app_chain ac ON g.id = ac.next_group \ + WHERE ac.depth < 64 \ + ) \ + SELECT ac.effective_app_id, ac.depth, \ + r.id, r.app_id, r.group_id, r.script_id, r.host_kind, r.host, \ + r.host_param_name, r.path_kind, r.path, r.method, \ + r.dispatch_mode, r.enabled, r.created_at \ + FROM app_chain ac \ + JOIN routes r ON (r.app_id = ac.owner_app OR r.group_id = ac.owner_group) \ + ORDER BY ac.effective_app_id, ac.depth", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|er| EffectiveRoute { + effective_app_id: er.effective_app_id.into(), + depth: er.depth, + route: er.row.into(), + }) + .collect()) + } + async fn list_for_script( &self, script_id: ScriptId, ) -> Result, ScriptRepositoryError> { let rows = sqlx::query_as::<_, RouteRow>( - "SELECT id, app_id, script_id, host_kind, host, host_param_name, \ + "SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \ path_kind, path, method, dispatch_mode, enabled, created_at \ FROM routes WHERE script_id = $1 ORDER BY created_at", ) @@ -172,15 +267,21 @@ pub(crate) async fn insert_route_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, input: &NewRoute, ) -> Result { + // §11 tail: an app owns a concrete route, a group owns a template. + let (owner_app_id, owner_group_id) = match input.owner { + ScriptOwner::App(a) => (Some(a.into_inner()), None), + ScriptOwner::Group(g) => (None, Some(g.into_inner())), + }; let res = sqlx::query_as::<_, RouteRow>( "INSERT INTO routes ( \ - app_id, script_id, host_kind, host, host_param_name, \ + app_id, group_id, script_id, host_kind, host, host_param_name, \ path_kind, path, method, dispatch_mode, enabled \ - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ - RETURNING id, app_id, script_id, host_kind, host, host_param_name, \ + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ + RETURNING id, app_id, group_id, script_id, host_kind, host, host_param_name, \ path_kind, path, method, dispatch_mode, enabled, created_at", ) - .bind(input.app_id.into_inner()) + .bind(owner_app_id) + .bind(owner_group_id) .bind(input.script_id.into_inner()) .bind(host_kind_str(input.host_kind)) .bind(&input.host) @@ -225,7 +326,11 @@ pub(crate) async fn delete_route_tx( #[derive(sqlx::FromRow)] struct RouteRow { id: Uuid, - app_id: Uuid, + app_id: Option, + // `default` so any RETURNING/SELECT that predates the column still + // hydrates; every query in this module now lists it explicitly. + #[sqlx(default)] + group_id: Option, script_id: Uuid, host_kind: String, host: String, @@ -238,11 +343,22 @@ struct RouteRow { created_at: chrono::DateTime, } +/// Row shape for [`RouteRepository::list_effective`]: the effective app + +/// owner depth, with the underlying route columns flattened in via `flatten`. +#[derive(sqlx::FromRow)] +struct EffectiveRouteRow { + effective_app_id: Uuid, + depth: i32, + #[sqlx(flatten)] + row: RouteRow, +} + impl From for Route { fn from(r: RouteRow) -> Self { Self { id: r.id, - app_id: r.app_id.into(), + app_id: r.app_id.map(Into::into), + group_id: r.group_id.map(Into::into), script_id: r.script_id.into(), host_kind: match r.host_kind.as_str() { "strict" => HostKind::Strict, diff --git a/crates/shared/src/route.rs b/crates/shared/src/route.rs index b0106b4..d1568f8 100644 --- a/crates/shared/src/route.rs +++ b/crates/shared/src/route.rs @@ -7,7 +7,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{AppId, ScriptId}; +use crate::{AppId, GroupId, ScriptId}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -72,10 +72,14 @@ impl DispatchMode { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Route { pub id: Uuid, - /// Owning app. Always equals `scripts.app_id` for the bound script. - /// Carried on the route row so the orchestrator can partition the - /// route table without joining back to scripts on every refresh. - pub app_id: AppId, + /// Polymorphic owner (§11 tail): an **app** owns a concrete route; a + /// **group** owns a route TEMPLATE, expanded into every descendant app's + /// in-memory slice at rebuild. Exactly one is set (DB CHECK). Mirrors + /// `Trigger`. For an app route, `app_id` equals `scripts.app_id` for the + /// bound script — carried here so the orchestrator can partition the route + /// table without joining back to scripts on every refresh. + pub app_id: Option, + pub group_id: Option, pub script_id: ScriptId, pub host_kind: HostKind,