From b79d8ef47d06047ca58d0f443fa648430c667b54 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 29 Jun 2026 21:51:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(apply):=20declarative=20shared-collection?= =?UTF-8?q?=20reconcile=20(=C2=A711.6=20C4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread group-collection markers through the apply engine — a name-only resource modeled field-for-field on extension_points (§5.5): Bundle.collections, CurrentState.collection_names, Plan.collections (+ is_noop), diff_collections (declared→Create / live-undeclared→Delete / else NoOp), load_current loads the names, reconcile_node_tx inserts on Create + deletes on prune via group_collection_repo, state_token folds in coll|, validate_bundle rejects empty/duplicate names (no reserved-name guard — a collection is a data namespace, not an importable module), ApplyReport gains collections_created/ deleted. Pruning a marker removes only the declaration; the group_kv_entries data survives until the owning group is deleted. The apply engine stays owner-generic; restricting declarations to [group] nodes is enforced at the CLI manifest layer (next commit). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/manager-core/src/apply_service.rs | 121 ++++++++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 44b2812..81e3a33 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -88,6 +88,12 @@ pub struct Bundle { /// optional default body is a co-located `kind = module` script. #[serde(default)] pub extension_points: Vec, + /// Declared shared group-collection *names* (§11.6) — KV collections this + /// node offers as cross-app-shared. Name-only, like `extension_points`; the + /// data lives in `group_kv_entries`. Authored on `[group]` nodes only (the + /// CLI rejects it on `[app]`); the apply engine stays owner-generic. + #[serde(default)] + pub collections: Vec, } #[derive(Debug, Clone, Deserialize)] @@ -322,6 +328,8 @@ pub struct Plan { pub vars: Vec, #[serde(default)] pub extension_points: Vec, + #[serde(default)] + pub collections: Vec, } impl Plan { @@ -335,6 +343,7 @@ impl Plan { .chain(&self.secrets) .chain(&self.vars) .chain(&self.extension_points) + .chain(&self.collections) .all(|c| c.op == Op::NoOp) } } @@ -413,6 +422,8 @@ pub struct CurrentState { pub vars: Vec<(String, serde_json::Value)>, /// Extension-point marker names declared directly at this node (§5.5). pub extension_point_names: Vec, + /// Shared group-collection names declared directly at this node (§11.6). + pub collection_names: Vec, } /// One row of the read-only extension-point report (§5.5). @@ -810,6 +821,23 @@ impl ApplyService { } } + // 3d. Shared group-collection markers (§11.6) — insert each Create + // (idempotent). Name-only identity; deletes happen in prune. Pruning a + // marker hides the store but does NOT drop its `group_kv_entries` data + // (that dies only with the owning group). + for ch in &plan.collections { + if ch.op == Op::Create { + crate::group_collection_repo::insert_collection_tx( + &mut *tx, + owner.as_script_owner(), + &ch.key, + ) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + report.collections_created += 1; + } + } + // 4. Prune (only with --prune): delete stale triggers, then scripts // (route removals already happened in the route delete-pass above). // Secret pruning is deliberately deferred (destructive + irreversible): @@ -899,6 +927,22 @@ impl ApplyService { report.extension_points_deleted += 1; } } + + // Shared group-collection markers are prunable config too (§11.6). + // Only the marker is removed here — the data survives until the + // owning group is deleted. + for ch in &plan.collections { + if ch.op == Op::Delete { + crate::group_collection_repo::delete_collection_tx( + &mut *tx, + owner.as_script_owner(), + &ch.key, + ) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + report.collections_deleted += 1; + } + } } Ok(name_to_id) } @@ -1924,6 +1968,23 @@ impl ApplyService { ))); } } + + // Shared group collections (§11.6): unique names. No reserved-name + // guard — a collection name is data namespace, not an importable + // module, so it can't shadow an SDK namespace. + let mut collection_names: HashSet = HashSet::new(); + for name in &bundle.collections { + if name.is_empty() { + return Err(ApplyError::Invalid( + "shared collection name must not be empty".into(), + )); + } + if !collection_names.insert(name.to_lowercase()) { + return Err(ApplyError::Invalid(format!( + "duplicate shared collection `{name}`" + ))); + } + } Ok(()) } @@ -1976,6 +2037,11 @@ impl ApplyService { crate::extension_point_repo::list_for_owner(&self.pool, owner.as_script_owner()) .await .map_err(|e| ApplyError::Backend(e.to_string()))?; + // Shared group-collection markers declared directly at this node (§11.6). + let collection_names = + crate::group_collection_repo::list_for_owner(&self.pool, owner.as_script_owner()) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; Ok(CurrentState { scripts, routes, @@ -1983,6 +2049,7 @@ impl ApplyService { secret_names, vars, extension_point_names, + collection_names, }) } @@ -2047,6 +2114,7 @@ fn compute_diff_with_names( secrets: diff_secrets(current, bundle), vars: diff_vars(current, bundle), extension_points: diff_extension_points(current, bundle), + collections: diff_collections(current, bundle), } } @@ -2562,6 +2630,42 @@ fn diff_extension_points(current: &CurrentState, bundle: &Bundle) -> Vec Vec { + let live: HashSet<&str> = current + .collection_names + .iter() + .map(String::as_str) + .collect(); + let declared: HashSet<&str> = bundle.collections.iter().map(String::as_str).collect(); + + let mut out = Vec::new(); + for name in &bundle.collections { + out.push(ResourceChange { + op: if live.contains(name.as_str()) { + Op::NoOp + } else { + Op::Create + }, + key: name.clone(), + detail: None, + }); + } + for name in ¤t.collection_names { + if !declared.contains(name.as_str()) { + out.push(ResourceChange { + op: Op::Delete, + key: name.clone(), + detail: Some("on server, not declared".into()), + }); + } + } + out +} + /// Reject any email trigger whose referenced secret isn't set, so `plan` and /// `apply` give the same answer (apply also fails in `resolve_and_seal`, but /// only after taking the lock — surfacing it here keeps plan honest). @@ -2852,6 +2956,10 @@ pub struct ApplyReport { pub extension_points_created: u32, #[serde(default)] pub extension_points_deleted: u32, + #[serde(default)] + pub collections_created: u32, + #[serde(default)] + pub collections_deleted: u32, #[serde(skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, } @@ -3022,7 +3130,8 @@ fn state_token_with_names( + current.triggers.len() + current.secret_names.len() + current.vars.len() - + current.extension_point_names.len(), + + current.extension_point_names.len() + + current.collection_names.len(), ); for s in ¤t.scripts { parts.push(format!( @@ -3081,6 +3190,10 @@ fn state_token_with_names( for n in ¤t.extension_point_names { parts.push(format!("ep|{n}")); } + // Shared group-collection markers are name-only too (§11.6). + for n in ¤t.collection_names { + parts.push(format!("coll|{n}")); + } // Order-independent: sort the per-resource tokens before hashing. parts.sort_unstable(); let mut h: u64 = 0xcbf2_9ce4_8422_2325; @@ -3162,6 +3275,7 @@ mod tests { secrets: vec!["S".into()], vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), + collections: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts.len(), 1); @@ -3185,6 +3299,7 @@ mod tests { secrets: vec!["S".into()], vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), + collections: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert!(plan.is_noop(), "expected all no-op, got {plan:?}"); @@ -3203,6 +3318,7 @@ mod tests { secrets: vec![], vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), + collections: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts[0].op, Op::Update); @@ -3222,6 +3338,7 @@ mod tests { secrets: vec![], vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), + collections: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.scripts[0].op, Op::Delete); @@ -3269,6 +3386,7 @@ mod tests { secrets: vec![], vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), + collections: Vec::new(), }; let plan = compute_diff(¤t, &bundle); assert_eq!(plan.routes[0].op, Op::Update); @@ -3282,6 +3400,7 @@ mod tests { secrets: vec![], vars: std::collections::BTreeMap::new(), extension_points: Vec::new(), + collections: Vec::new(), } }