feat(hierarchies): declarative group create/reparent — tree shape (M2)
M2 of the remaining-hierarchies work. `pic apply --dir` now owns the org-tree
SHAPE, not just node content: a `[group]` manifest for a group that doesn't
exist is CREATED under its declared parent, and an existing group whose declared
parent changed is REPARENTED — all inside the single tree-apply transaction
(create + reparent; structural prune is deferred to M3 with the ownership layer).
group_repo: extract transaction-aware structural mutations — `create_group_tx`,
`reparent_group_tx` (the ancestor-walk cycle guard, now reading through the tx so
it sees in-tx writes), `delete_group_tx`, and `acquire_structural_lock_tx`. The
trait `create`/`reparent`/`delete` delegate to them (one SQL definition,
behavior-preserving: the coarse structural advisory lock + structure_version
bump + delete=RESTRICT all preserved).
apply_service: `TreeNode` gains `parent_slug` + `name`. `prepare_tree` classifies
group nodes into existing (resolved id, maybe reparent) vs to-create (absent →
deferred), returning a `PreparedTree { prepared, creates, reparents, token }`.
`apply_tree` adds Phase 0 (`reconcile_tree_structure_tx`): create absent groups
parent-first (topo loop with cycle/unresolved-parent detection) and reparent
existing ones, then Phase A/B reconcile content as before. A to-create group
reconciles against an empty CurrentState (all-Create) — so it needs no DB read
and sidesteps the pool-vs-tx coupling. The bound-plan token folds a
declared-absent marker, so a group created out-of-band between plan and apply
trips StateMoved.
authz (apply_api `authz_tree`): a to-create group mirrors the interactive create
gate — root-level needs `InstanceCreateGroup`, a subgroup under an existing
parent needs only `GroupAdmin(parent)`; a reparent needs `GroupAdmin` at the
group, the SOURCE parent, and the DESTINATION parent (§5.6, parity with
`reparent_group`). No 404 on a to-create node.
CLI: `[group] parent` manifest key (else the parent is inferred from the nearest
ancestor directory's group); `build_tree` emits `parent_slug` + `name` per group
node; the apply report shows a `groups +N reparented M` line.
Reviewed by subagent (core mechanism verified sound: topo termination, empty-
CurrentState reconcile, in-tx cycle guard, atomicity, StateMoved, idempotency);
fixed the two authz-parity gaps it found (missing source-parent check on
reparent; over-gated subgroup create). Tests: a new `tree_shape` journey
(create + reparent + no-op re-plan); 393 manager-core lib + the Phase-5 tree/
group/ext journeys all green; clippy -D warnings clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -191,27 +191,10 @@ impl GroupRepository for PostgresGroupRepository {
|
||||
description: Option<&str>,
|
||||
parent_id: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let res = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"INSERT INTO groups (slug, name, description, parent_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(parent_id.map(GroupId::into_inner))
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
|
||||
GroupRepositoryError::Conflict("parent group does not exist".into()),
|
||||
),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let g = create_group_tx(&mut tx, slug, name, description, parent_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
async fn rename(
|
||||
@@ -247,96 +230,165 @@ impl GroupRepository for PostgresGroupRepository {
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
// Coarse structural lock: serialize all structural mutations so the
|
||||
// cycle guard + parent write can't interleave with a concurrent
|
||||
// reparent and race into a cycle.
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(GROUP_STRUCTURAL_LOCK_KEY)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(parent) = new_parent {
|
||||
if parent == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"a group cannot be its own parent".into(),
|
||||
));
|
||||
}
|
||||
// Cycle guard: walk from the destination up to the root; if we
|
||||
// reach `id`, the move would place `id` beneath itself.
|
||||
let mut cursor = Some(parent);
|
||||
let mut hops = 0u32;
|
||||
while let Some(node) = cursor {
|
||||
if node == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"cannot reparent a group beneath one of its own descendants".into(),
|
||||
));
|
||||
}
|
||||
hops += 1;
|
||||
if hops > 64 {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"group ancestry exceeds the maximum depth".into(),
|
||||
));
|
||||
}
|
||||
let parent_of: Option<(Option<Uuid>,)> =
|
||||
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
|
||||
.bind(node.into_inner())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
match parent_of {
|
||||
Some((p,)) => cursor = p.map(GroupId::from),
|
||||
// Destination parent doesn't exist.
|
||||
None => {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"destination parent group does not exist".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"UPDATE groups SET \
|
||||
parent_id = $2, \
|
||||
structure_version = structure_version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(new_parent.map(GroupId::into_inner))
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(GroupRepositoryError::NotFound(id));
|
||||
};
|
||||
// cycle guard + parent write can't race a concurrent reparent.
|
||||
acquire_structural_lock_tx(&mut tx).await?;
|
||||
let g = reparent_group_tx(&mut tx, id, new_parent).await?;
|
||||
tx.commit().await?;
|
||||
Ok(row.into())
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
|
||||
// Pre-check for a clean message; the FK RESTRICT is the real guard.
|
||||
let counts = self.child_counts(id).await?;
|
||||
if !counts.is_empty() {
|
||||
return Err(GroupRepositoryError::Conflict(format!(
|
||||
"group still has {} subgroup(s) and {} app(s); move or delete them first",
|
||||
counts.subgroups, counts.apps
|
||||
)));
|
||||
let mut tx = self.pool.begin().await?;
|
||||
delete_group_tx(&mut tx, id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Transaction-aware structural mutations (Phase 5+ declarative tree apply).
|
||||
//
|
||||
// The declarative `apply_tree` reconciles a whole subtree in ONE transaction,
|
||||
// so group create/reparent/delete must run inside the caller's tx (not on the
|
||||
// pool) to stay all-or-nothing. These free functions hold the SQL; the trait
|
||||
// methods above delegate to them (begin → fn → commit) so there is one SQL
|
||||
// definition each. The caller takes [`acquire_structural_lock_tx`] ONCE before
|
||||
// the structure phase, so the cycle guard + parent writes serialize against
|
||||
// concurrent reparents exactly as the single-shot `reparent` does.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Take the coarse structural lock inside the caller's transaction. Held until
|
||||
/// the tx commits/rolls back. Call once before any `*_group_tx` mutation.
|
||||
pub(crate) async fn acquire_structural_lock_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), GroupRepositoryError> {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(GROUP_STRUCTURAL_LOCK_KEY)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a group inside the caller's tx. Maps unique/FK violations to a clean
|
||||
/// conflict. (The structural lock is not required for a pure insert, but the
|
||||
/// apply path holds it for the whole phase anyway.)
|
||||
pub(crate) async fn create_group_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
parent_id: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let res = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"INSERT INTO groups (slug, name, description, parent_id) \
|
||||
VALUES ($1, $2, $3, $4) RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(parent_id.map(GroupId::into_inner))
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
|
||||
GroupRepositoryError::Conflict("parent group does not exist".into()),
|
||||
),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reparent a group inside the caller's tx. Runs the ancestor-walk cycle guard
|
||||
/// against the IN-TX tree (so it sees parent writes made earlier in the same
|
||||
/// apply). The caller must already hold [`acquire_structural_lock_tx`].
|
||||
pub(crate) async fn reparent_group_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: GroupId,
|
||||
new_parent: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
if let Some(parent) = new_parent {
|
||||
if parent == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"a group cannot be its own parent".into(),
|
||||
));
|
||||
}
|
||||
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
match res {
|
||||
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
|
||||
Ok(_) => Ok(()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
// Lost a race with a concurrent child insert.
|
||||
Err(GroupRepositoryError::Conflict(
|
||||
"group still has descendants; move or delete them first".into(),
|
||||
))
|
||||
let mut cursor = Some(parent);
|
||||
let mut hops = 0u32;
|
||||
while let Some(node) = cursor {
|
||||
if node == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"cannot reparent a group beneath one of its own descendants".into(),
|
||||
));
|
||||
}
|
||||
hops += 1;
|
||||
if hops > 64 {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"group ancestry exceeds the maximum depth".into(),
|
||||
));
|
||||
}
|
||||
let parent_of: Option<(Option<Uuid>,)> =
|
||||
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
|
||||
.bind(node.into_inner())
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
match parent_of {
|
||||
Some((p,)) => cursor = p.map(GroupId::from),
|
||||
None => {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"destination parent group does not exist".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"UPDATE groups SET parent_id = $2, structure_version = structure_version + 1, \
|
||||
updated_at = NOW() WHERE id = $1 RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(new_parent.map(GroupId::into_inner))
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or(GroupRepositoryError::NotFound(id))
|
||||
}
|
||||
|
||||
/// Delete an empty group inside the caller's tx (delete = RESTRICT). Refused
|
||||
/// with a clean conflict if it still has child groups or apps.
|
||||
pub(crate) async fn delete_group_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: GroupId,
|
||||
) -> Result<(), GroupRepositoryError> {
|
||||
let counts: (i64, i64) = sqlx::query_as(
|
||||
"SELECT (SELECT COUNT(*) FROM groups WHERE parent_id = $1), \
|
||||
(SELECT COUNT(*) FROM apps WHERE group_id = $1)",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
if counts.0 != 0 || counts.1 != 0 {
|
||||
return Err(GroupRepositoryError::Conflict(format!(
|
||||
"group still has {} subgroup(s) and {} app(s); move or delete them first",
|
||||
counts.0, counts.1
|
||||
)));
|
||||
}
|
||||
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
|
||||
Ok(_) => Ok(()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
Err(GroupRepositoryError::Conflict(
|
||||
"group still has descendants; move or delete them first".into(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
Reference in New Issue
Block a user