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

@@ -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(),

View File

@@ -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(),

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(),

View File

@@ -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<Vec<Route>, ScriptRepositoryError>;
@@ -33,6 +50,36 @@ pub trait RouteRepository: Send + Sync {
/// (not a path param).
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, 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<Vec<Route>, 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<Vec<EffectiveRoute>, 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<Vec<Route>, 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<Option<Route>, 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<Vec<Route>, 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<Vec<Route>, 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<Vec<EffectiveRoute>, 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<Vec<Route>, 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<Route, ScriptRepositoryError> {
// §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<Uuid>,
// `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<Uuid>,
script_id: Uuid,
host_kind: String,
host: String,
@@ -238,11 +343,22 @@ struct RouteRow {
created_at: chrono::DateTime<chrono::Utc>,
}
/// 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<RouteRow> 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,