refactor(apply): unify divergence detection and drop the plan-path N+1
Review #3 (LOW). `divergence_preview` (plan path) compared parent slugs case-sensitively from its own `get_by_slug`, while the apply path (`reconcile_group_structure_tx`) compares resolved parent ids. On a mixed-case parent slug the two could disagree — `pic plan` reporting `diverged` while `pic apply` hard-errors "parent does not exist". `resolve_existing_group_ids` becomes `load_existing_groups`, returning the full `Group` rows it already loads; `plan_tree` derives the id map from them and passes the map to `divergence_preview`, which now reads each node's own row without a per-node `get_by_slug` (kills the N+1), resolves an in-tree parent's slug from the same map, and compares parent slugs case-insensitively so the preview agrees with the apply path's id-based check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1679,7 +1679,9 @@ impl ApplyService {
|
||||
bundle: &TreeBundle,
|
||||
project: Option<&ProjectDecl>,
|
||||
) -> Result<TreePlanResult, ApplyError> {
|
||||
let resolved = self.resolve_existing_group_ids(bundle).await?;
|
||||
let existing = self.load_existing_groups(bundle).await?;
|
||||
let resolved: HashMap<String, GroupId> =
|
||||
existing.iter().map(|(k, g)| (k.clone(), g.id)).collect();
|
||||
let (prepared, to_create, token) = self.prepare_tree(bundle, &resolved).await?;
|
||||
// §6/§7 M3: attach-point ceiling preview (consistent with apply_tree).
|
||||
if let Some(pg_slug) = project.and_then(|p| p.parent_group.as_deref()) {
|
||||
@@ -1694,7 +1696,7 @@ impl ApplyService {
|
||||
let ownership = self.ownership_preview(p.owner, declared).await?;
|
||||
// §6 M2: for an existing group node, preview a structural divergence
|
||||
// (server parent ≠ manifest parent) before apply.
|
||||
let structure = self.divergence_preview(p.node).await?;
|
||||
let structure = self.divergence_preview(p.node, &existing).await?;
|
||||
nodes.push(NodePlan {
|
||||
kind: p.node.kind,
|
||||
slug: p.node.slug.clone(),
|
||||
@@ -1932,12 +1934,14 @@ impl ApplyService {
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// §6: resolve every GROUP node's slug → id on the pool, skipping ones that
|
||||
/// don't exist yet (they are to-create, previewed by the plan path).
|
||||
async fn resolve_existing_group_ids(
|
||||
/// §6: load every EXISTING group node (lowercased slug → its full row) on
|
||||
/// the pool, skipping ones that don't exist yet (to-create, previewed by the
|
||||
/// plan path). Returns the whole `Group` so the divergence preview reads
|
||||
/// `parent_id` (and resolves an in-tree parent's slug) without re-fetching.
|
||||
async fn load_existing_groups(
|
||||
&self,
|
||||
bundle: &TreeBundle,
|
||||
) -> Result<HashMap<String, GroupId>, ApplyError> {
|
||||
) -> Result<HashMap<String, picloud_shared::Group>, ApplyError> {
|
||||
let mut map = HashMap::new();
|
||||
for n in &bundle.nodes {
|
||||
if n.kind == NodeKind::Group {
|
||||
@@ -1947,7 +1951,7 @@ impl ApplyService {
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
map.insert(n.slug.to_lowercase(), g.id);
|
||||
map.insert(n.slug.to_lowercase(), g);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1957,32 +1961,36 @@ impl ApplyService {
|
||||
/// §6 M2: preview an EXISTING group node's structural divergence — `Some`
|
||||
/// only when the server's parent differs from the manifest's declared
|
||||
/// (directory-nesting) parent. `None` for an app node, a to-create group, or
|
||||
/// an in-sync group.
|
||||
/// an in-sync group. Reads the node's own row from the pre-loaded `existing`
|
||||
/// map (no per-node `get_by_slug`), resolves the server parent's slug from
|
||||
/// that map when the parent is another tree node (else one `get_by_id`), and
|
||||
/// compares parent slugs **case-insensitively** so this preview agrees with
|
||||
/// the apply path's id-based check (which lowercases) on a mixed-case slug.
|
||||
async fn divergence_preview(
|
||||
&self,
|
||||
node: &TreeNode,
|
||||
existing: &HashMap<String, picloud_shared::Group>,
|
||||
) -> Result<Option<StructurePreview>, ApplyError> {
|
||||
if node.kind != NodeKind::Group {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(group) = self
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
else {
|
||||
let Some(group) = existing.get(&node.slug.to_lowercase()) else {
|
||||
return Ok(None); // to-create — not a divergence
|
||||
};
|
||||
let server_parent = match group.parent_id {
|
||||
Some(pid) => self
|
||||
.groups
|
||||
.get_by_id(pid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.map(|g| g.slug),
|
||||
Some(pid) => match existing.values().find(|g| g.id == pid) {
|
||||
Some(g) => Some(g.slug.clone()),
|
||||
None => self
|
||||
.groups
|
||||
.get_by_id(pid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.map(|g| g.slug),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
if server_parent.as_deref() == node.parent.as_deref() {
|
||||
let norm = |s: Option<&str>| s.map(str::to_lowercase);
|
||||
if norm(server_parent.as_deref()) == norm(node.parent.as_deref()) {
|
||||
return Ok(None); // in-sync
|
||||
}
|
||||
Ok(Some(StructurePreview {
|
||||
|
||||
Reference in New Issue
Block a user