feat(apply): declarative shared-collection reconcile (§11.6 C4)
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|<name>, 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) <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,12 @@ pub struct Bundle {
|
||||
/// optional default body is a co-located `kind = module` script.
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -322,6 +328,8 @@ pub struct Plan {
|
||||
pub vars: Vec<ResourceChange>,
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ResourceChange>,
|
||||
#[serde(default)]
|
||||
pub collections: Vec<ResourceChange>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// Shared group-collection names declared directly at this node (§11.6).
|
||||
pub collection_names: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String> = 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<Resourc
|
||||
out
|
||||
}
|
||||
|
||||
/// Diff shared group-collection markers by name (§11.6). Same shape as
|
||||
/// `diff_extension_points`: a declared-but-absent name is a Create, a
|
||||
/// live-but-undeclared name is a Delete (pruned under `--prune`). Pruning a
|
||||
/// marker hides the store but never drops its `group_kv_entries` data.
|
||||
fn diff_collections(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||||
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<String>,
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user