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:
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AppId>,
|
||||
pub group_id: Option<GroupId>,
|
||||
pub script_id: ScriptId,
|
||||
|
||||
pub host_kind: HostKind,
|
||||
|
||||
Reference in New Issue
Block a user