feat(modules): no-provider plan check + read-only extension-points ls (§5.5 C4)
- apply: `check_extension_points_provided` (app nodes) — every EP visible on
the app's chain (its own + inherited) must have a provider: a same-apply
module, or one resolvable up-chain (override or default body). Else a clean
`pic plan` error instead of a runtime failure. Single-node only (the tree
path leans on the runtime backstop, like the dangling-import check).
- read-only surface: `extension_point_report` + `ExtensionPointInfo`;
`GET /{apps|groups}/{id}/extension-points` (AppRead / GroupScriptsRead);
`pic extension-points ls --app|--group` showing declared-vs-inherited + the
resolved provider (`app override` / `inherited default` / `unset`).
- `pic pull` round-trips an app's OWN declared EPs (filtered `declared_here`),
so pull→plan stays idempotent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,8 +32,8 @@ use std::sync::Arc;
|
||||
use picloud_orchestrator_core::routing::{pattern, RouteTable};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, DispatchMode, DocsEventOp, FilesEventOp, GroupId, HostKind, KvEventOp,
|
||||
MasterKey, PathKind, Route, Script, ScriptId, ScriptKind, ScriptSandbox, ScriptValidator,
|
||||
TriggerId,
|
||||
MasterKey, PathKind, Route, Script, ScriptId, ScriptKind, ScriptOwner, ScriptSandbox,
|
||||
ScriptValidator, TriggerId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
@@ -415,6 +415,17 @@ pub struct CurrentState {
|
||||
pub extension_point_names: Vec<String>,
|
||||
}
|
||||
|
||||
/// One row of the read-only extension-point report (§5.5).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExtensionPointInfo {
|
||||
pub name: String,
|
||||
/// True when this node owns the marker (vs inheriting it from an ancestor).
|
||||
pub declared_here: bool,
|
||||
/// Resolved provider for an app (`app override` / `inherited default`), or
|
||||
/// `None` when unset (no provider — a plan error) or for a group node.
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -499,6 +510,7 @@ impl ApplyService {
|
||||
let inherited = self.resolve_inherited_targets_for(owner, bundle).await?;
|
||||
self.validate_bundle_for(owner, bundle, &keys_set(&inherited))?;
|
||||
self.check_imports_resolve(owner, bundle).await?;
|
||||
self.check_extension_points_provided(owner, bundle).await?;
|
||||
if let ApplyOwner::App(app_id) = owner {
|
||||
self.validate_route_hosts(app_id, bundle).await?;
|
||||
}
|
||||
@@ -926,6 +938,7 @@ impl ApplyService {
|
||||
let inherited = self.resolve_inherited_targets_for(owner, bundle).await?;
|
||||
self.validate_bundle_for(owner, bundle, &keys_set(&inherited))?;
|
||||
self.check_imports_resolve(owner, bundle).await?;
|
||||
self.check_extension_points_provided(owner, bundle).await?;
|
||||
if let ApplyOwner::App(app_id) = owner {
|
||||
self.validate_route_hosts(app_id, bundle).await?;
|
||||
}
|
||||
@@ -1638,6 +1651,121 @@ impl ApplyService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read-only extension-point report for a node (§5.5). For an **app**:
|
||||
/// every EP visible on its chain, each flagged `declared_here` (owned by
|
||||
/// the app, vs inherited) with its resolved `provider` (`app override` /
|
||||
/// `inherited default` / `null` = unset). For a **group**: its own declared
|
||||
/// names (no single app to resolve a provider against). Backs the read-only
|
||||
/// `extension-points ls` and the `pull` round-trip.
|
||||
pub async fn extension_point_report(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
) -> Result<Vec<ExtensionPointInfo>, ApplyError> {
|
||||
let pool = &self.pool;
|
||||
match owner {
|
||||
ApplyOwner::Group(g) => {
|
||||
let names =
|
||||
crate::extension_point_repo::list_for_owner(pool, ScriptOwner::Group(g))
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
Ok(names
|
||||
.into_iter()
|
||||
.map(|name| ExtensionPointInfo {
|
||||
name,
|
||||
declared_here: true,
|
||||
provider: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
ApplyOwner::App(a) => {
|
||||
let own: HashSet<String> =
|
||||
crate::extension_point_repo::list_for_owner(pool, ScriptOwner::App(a))
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.into_iter()
|
||||
.map(|n| n.to_lowercase())
|
||||
.collect();
|
||||
let visible = crate::extension_point_repo::list_on_app_chain(pool, a)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
let mut out = Vec::with_capacity(visible.len());
|
||||
for name in visible {
|
||||
let provider = match self
|
||||
.modules
|
||||
.resolve(ScriptOwner::App(a), &name)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.and_then(|m| m.owner())
|
||||
{
|
||||
Some(ScriptOwner::App(_)) => Some("app override".to_string()),
|
||||
Some(ScriptOwner::Group(_)) => Some("inherited default".to_string()),
|
||||
None => None,
|
||||
};
|
||||
out.push(ExtensionPointInfo {
|
||||
declared_here: own.contains(&name.to_lowercase()),
|
||||
name,
|
||||
provider,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// §5.5 no-provider plan check (app nodes only). Every extension point
|
||||
/// visible to the app — declared in this bundle or inherited from an
|
||||
/// ancestor — must have a provider: a module of that name the app provides
|
||||
/// (in this bundle, or resolvable up its chain as an override or a default
|
||||
/// body). Otherwise the import would fail at runtime, so refuse at plan.
|
||||
/// Group nodes have no single inheriting app, so the check is per-app.
|
||||
/// Single-node only (the tree path leans on the runtime backstop).
|
||||
async fn check_extension_points_provided(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
bundle: &Bundle,
|
||||
) -> Result<(), ApplyError> {
|
||||
let ApplyOwner::App(app_id) = owner else {
|
||||
return Ok(());
|
||||
};
|
||||
// A same-apply app module provides its EP without being committed yet.
|
||||
let local: HashSet<String> = bundle
|
||||
.scripts
|
||||
.iter()
|
||||
.filter(|s| s.kind == ScriptKind::Module)
|
||||
.map(|s| s.name.to_lowercase())
|
||||
.collect();
|
||||
// EP names visible to the app: its own (this bundle) ∪ inherited.
|
||||
let mut names: HashSet<String> = bundle
|
||||
.extension_points
|
||||
.iter()
|
||||
.map(|n| n.to_lowercase())
|
||||
.collect();
|
||||
for n in crate::extension_point_repo::list_on_app_chain(&self.pool, app_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
names.insert(n.to_lowercase());
|
||||
}
|
||||
for name in names {
|
||||
if local.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
let found = self
|
||||
.modules
|
||||
.resolve(picloud_shared::ScriptOwner::App(app_id), &name)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
if found.is_none() {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"extension point `{name}` has no provider for this app — \
|
||||
declare a module named `{name}` here or ensure a default \
|
||||
body exists up-chain"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `inherited_endpoints` (lowercased names) are group-owned endpoint
|
||||
/// scripts reachable from the app's chain that a route/trigger may bind to
|
||||
/// even though the manifest doesn't declare them (Phase 4). They count as
|
||||
|
||||
Reference in New Issue
Block a user