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 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::app_repo::AppRepository;
|
||||||
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
|
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
|
||||||
@@ -76,7 +76,7 @@ async fn seed_into(
|
|||||||
|
|
||||||
routes
|
routes
|
||||||
.create(NewRoute {
|
.create(NewRoute {
|
||||||
app_id: default.id,
|
owner: ScriptOwner::App(default.id),
|
||||||
script_id: script.id,
|
script_id: script.id,
|
||||||
host_kind: HostKind::Any,
|
host_kind: HostKind::Any,
|
||||||
host: String::new(),
|
host: String::new(),
|
||||||
|
|||||||
@@ -3216,7 +3216,7 @@ async fn insert_bundle_route(
|
|||||||
br: &BundleRoute,
|
br: &BundleRoute,
|
||||||
) -> Result<(), ApplyError> {
|
) -> Result<(), ApplyError> {
|
||||||
let new = NewRoute {
|
let new = NewRoute {
|
||||||
app_id,
|
owner: ScriptOwner::App(app_id),
|
||||||
script_id,
|
script_id,
|
||||||
host_kind: br.host_kind,
|
host_kind: br.host_kind,
|
||||||
host: br.host.clone(),
|
host: br.host.clone(),
|
||||||
@@ -3553,7 +3553,8 @@ mod tests {
|
|||||||
let sid = s.id;
|
let sid = s.id;
|
||||||
let route = Route {
|
let route = Route {
|
||||||
id: uuid::Uuid::new_v4(),
|
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,
|
script_id: sid,
|
||||||
host_kind: HostKind::Any,
|
host_kind: HostKind::Any,
|
||||||
host: String::new(),
|
host: String::new(),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use axum::{
|
|||||||
Extension, Json, Router,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
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 serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
|||||||
let created = state
|
let created = state
|
||||||
.routes
|
.routes
|
||||||
.create(NewRoute {
|
.create(NewRoute {
|
||||||
app_id,
|
owner: ScriptOwner::App(app_id),
|
||||||
script_id,
|
script_id,
|
||||||
host_kind: input.host_kind,
|
host_kind: input.host_kind,
|
||||||
host: input.host,
|
host: input.host,
|
||||||
@@ -259,10 +259,14 @@ async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
|||||||
.get(route_id)
|
.get(route_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(RouteApiError::RouteNotFound(route_id))?;
|
.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(
|
require(
|
||||||
state.authz.as_ref(),
|
state.authz.as_ref(),
|
||||||
&principal,
|
&principal,
|
||||||
Capability::AppWriteRoute(route.app_id),
|
Capability::AppWriteRoute(route_app_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
state.routes.delete(route_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 disabled route (§4.3) is dropped from the match table entirely, so
|
||||||
// a request to it 404s indistinguishably from an absent route.
|
// a request to it 404s indistinguishably from an absent route.
|
||||||
.filter(|r| r.enabled)
|
.filter(|r| r.enabled)
|
||||||
.filter_map(|r| match compile_route(r) {
|
.filter_map(|r| {
|
||||||
Ok(compiled) => Some(compiled),
|
// A group-owned route TEMPLATE (`app_id` None) has no single app to
|
||||||
Err(e) => {
|
// compile into — it is materialized per descendant by the effective
|
||||||
tracing::warn!(
|
// rebuild (`compile_effective_routes`), not this app-only path.
|
||||||
route_id = %r.id,
|
let app_id = r.app_id?;
|
||||||
app_id = %r.app_id,
|
compile_one(r, app_id)
|
||||||
path = %r.path,
|
|
||||||
error = %e,
|
|
||||||
"skipping un-compilable stored route — it will not match; \
|
|
||||||
delete or fix it"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect()
|
.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 {
|
Ok(CompiledRoute {
|
||||||
route_id: r.id,
|
route_id: r.id,
|
||||||
app_id: r.app_id,
|
app_id,
|
||||||
script_id: r.script_id,
|
script_id: r.script_id,
|
||||||
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
||||||
path: pattern::parse_path(r.path_kind, &r.path)?,
|
path: pattern::parse_path(r.path_kind, &r.path)?,
|
||||||
@@ -631,7 +649,8 @@ mod tests {
|
|||||||
fn route_with_path(path: &str) -> Route {
|
fn route_with_path(path: &str) -> Route {
|
||||||
Route {
|
Route {
|
||||||
id: Uuid::new_v4(),
|
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()),
|
script_id: ScriptId::from(Uuid::new_v4()),
|
||||||
host_kind: HostKind::Any,
|
host_kind: HostKind::Any,
|
||||||
host: String::new(),
|
host: String::new(),
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
//! after every write — see the route_admin module for the binding.
|
//! after every write — see the route_admin module for the binding.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
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 sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -12,7 +14,10 @@ use crate::repo::ScriptRepositoryError;
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewRoute {
|
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 script_id: ScriptId,
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -25,6 +30,18 @@ pub struct NewRoute {
|
|||||||
pub enabled: bool,
|
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]
|
#[async_trait]
|
||||||
pub trait RouteRepository: Send + Sync {
|
pub trait RouteRepository: Send + Sync {
|
||||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
@@ -33,6 +50,36 @@ pub trait RouteRepository: Send + Sync {
|
|||||||
/// (not a path param).
|
/// (not a path param).
|
||||||
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError>;
|
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError>;
|
||||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<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(
|
async fn list_for_script(
|
||||||
&self,
|
&self,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
@@ -64,7 +111,7 @@ impl PostgresRouteRepository {
|
|||||||
impl RouteRepository for PostgresRouteRepository {
|
impl RouteRepository for PostgresRouteRepository {
|
||||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, RouteRow>(
|
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 \
|
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||||
FROM routes ORDER BY 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> {
|
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, RouteRow>(
|
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 \
|
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||||
FROM routes WHERE id = $1",
|
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> {
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, RouteRow>(
|
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 \
|
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||||
FROM routes WHERE app_id = $1 ORDER BY 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())
|
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(
|
async fn list_for_script(
|
||||||
&self,
|
&self,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, RouteRow>(
|
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 \
|
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||||
FROM routes WHERE script_id = $1 ORDER BY 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>,
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
input: &NewRoute,
|
input: &NewRoute,
|
||||||
) -> Result<Route, ScriptRepositoryError> {
|
) -> 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>(
|
let res = sqlx::query_as::<_, RouteRow>(
|
||||||
"INSERT INTO routes ( \
|
"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 \
|
path_kind, path, method, dispatch_mode, enabled \
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
RETURNING id, app_id, group_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, dispatch_mode, enabled, created_at",
|
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(input.script_id.into_inner())
|
||||||
.bind(host_kind_str(input.host_kind))
|
.bind(host_kind_str(input.host_kind))
|
||||||
.bind(&input.host)
|
.bind(&input.host)
|
||||||
@@ -225,7 +326,11 @@ pub(crate) async fn delete_route_tx(
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct RouteRow {
|
struct RouteRow {
|
||||||
id: Uuid,
|
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,
|
script_id: Uuid,
|
||||||
host_kind: String,
|
host_kind: String,
|
||||||
host: String,
|
host: String,
|
||||||
@@ -238,11 +343,22 @@ struct RouteRow {
|
|||||||
created_at: chrono::DateTime<chrono::Utc>,
|
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 {
|
impl From<RouteRow> for Route {
|
||||||
fn from(r: RouteRow) -> Self {
|
fn from(r: RouteRow) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: r.id,
|
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(),
|
script_id: r.script_id.into(),
|
||||||
host_kind: match r.host_kind.as_str() {
|
host_kind: match r.host_kind.as_str() {
|
||||||
"strict" => HostKind::Strict,
|
"strict" => HostKind::Strict,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{AppId, ScriptId};
|
use crate::{AppId, GroupId, ScriptId};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -72,10 +72,14 @@ impl DispatchMode {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Route {
|
pub struct Route {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
/// Owning app. Always equals `scripts.app_id` for the bound script.
|
/// Polymorphic owner (§11 tail): an **app** owns a concrete route; a
|
||||||
/// Carried on the route row so the orchestrator can partition the
|
/// **group** owns a route TEMPLATE, expanded into every descendant app's
|
||||||
/// route table without joining back to scripts on every refresh.
|
/// in-memory slice at rebuild. Exactly one is set (DB CHECK). Mirrors
|
||||||
pub app_id: AppId,
|
/// `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 script_id: ScriptId,
|
||||||
|
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
|
|||||||
Reference in New Issue
Block a user