feat(hierarchies): cross-repo template expansion (M7)
Route/trigger templates declared at a group now fan out to EVERY descendant
app in the DB subtree, not just the app nodes present in the current
`pic apply --dir`. A template change at group N reaches subtree(N) across
repos, and a removed/disabled template reaps its expansions on those
descendants too.
- group_repo: recursive `descendant_app_ids` (+ `_tx`) — inverse of the
ancestor chain CTE, app_id-ordered, depth-bounded.
- apply_service: Phase B2 expands templates into descendants of every in-tree
group not already handled in Phase B. All descendant locks are taken up
front in the single sorted batch (their union is invariant under reparenting
in-tree groups), so there is no out-of-order mid-tx locking / deadlock.
Per-recipient write caps (AppWriteRoute / AppManageTriggers /
AppSecretsRead-for-email) are required AUTHORITATIVELY IN-TX against the
post-reparent, already-locked app — checked set == written set, no TOCTOU.
expand_*_templates_tx are now self-contained (load hand-declared identities
from the tx), so collisions are caught on descendants too. Blast radius now
counts the true DB subtree. Descendant expansion errors are tagged with the
app slug (e.g. an {env} template on a NULL-environment descendant names it).
- apply_api: the pre-tx descendant authz pass is removed in favour of the
in-tx gate (sound via the hierarchy-aware effective_app_role).
- templates journey: out-of-tree descendant at depth 1 AND 2 receives the
expansion and is reaped on template removal.
- doc §4.5: strike the in-apply-only scope limitation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
//! future CLI/orchestrator can detect structural drift (§6).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{Group, GroupId};
|
||||
use picloud_shared::{AppId, Group, GroupId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -523,6 +523,52 @@ pub(crate) async fn list_owned_groups_tx(
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Recursive descendant-app query, the inverse of the ancestor `CHAIN_LEVELS_CTE`
|
||||
/// in `config_resolver`: walk `groups.parent_id` DOWN from `$1` to collect the
|
||||
/// whole subtree of group ids, then every app whose `group_id` falls in it.
|
||||
/// Ordered by `app_id` so callers lock/iterate deterministically. Depth-bounded
|
||||
/// `< 64` as a runaway guard (the cycle guard already forbids real cycles).
|
||||
const DESCENDANT_APPS_SQL: &str = "\
|
||||
WITH RECURSIVE subtree AS ( \
|
||||
SELECT id, 0 AS depth FROM groups WHERE id = $1 \
|
||||
UNION ALL \
|
||||
SELECT g.id, s.depth + 1 FROM groups g JOIN subtree s ON g.parent_id = s.id \
|
||||
WHERE s.depth < 64 \
|
||||
) \
|
||||
SELECT a.id FROM apps a WHERE a.group_id IN (SELECT id FROM subtree) ORDER BY a.id";
|
||||
|
||||
/// Every app in the subtree rooted at `group_id` (the group itself and all
|
||||
/// descendant groups), `app_id`-ordered. The read-only pool variant — used by
|
||||
/// `plan` to size a template's true blast radius across the whole DB subtree,
|
||||
/// not just the apps present in the current apply.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates sqlx errors.
|
||||
pub async fn descendant_app_ids(
|
||||
pool: &PgPool,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<AppId>, GroupRepositoryError> {
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(DESCENDANT_APPS_SQL)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(id,)| id.into()).collect())
|
||||
}
|
||||
|
||||
/// The same subtree enumeration inside the apply tx, so groups/apps created
|
||||
/// earlier in this transaction (M2 structural reconcile) are included when
|
||||
/// fanning a template out to its descendants (§4.5, M7).
|
||||
pub(crate) async fn descendant_app_ids_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<AppId>, GroupRepositoryError> {
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(DESCENDANT_APPS_SQL)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(id,)| id.into()).collect())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct GroupRow {
|
||||
id: Uuid,
|
||||
|
||||
Reference in New Issue
Block a user