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:
MechaCat02
2026-06-29 20:31:14 +02:00
parent e62e073970
commit 82b4579317
8 changed files with 307 additions and 8 deletions

View File

@@ -7,9 +7,11 @@
//! helpers; it mirrors the var/secret tx-function style (free functions over a
//! `&PgPool` / `&mut Transaction`), keyed by the shared [`ScriptOwner`].
use picloud_shared::ScriptOwner;
use picloud_shared::{AppId, ScriptOwner};
use sqlx::{PgPool, Postgres, Transaction};
use crate::config_resolver::CHAIN_LEVELS_CTE;
/// List the EP names declared **directly at** `owner` (not inherited),
/// case-insensitively sorted. Used by `load_current` (apply diff), the
/// no-provider check, and the read-only `extension-points ls`.
@@ -35,6 +37,22 @@ pub async fn list_for_owner(pool: &PgPool, owner: ScriptOwner) -> Result<Vec<Str
Ok(rows.into_iter().map(|(n,)| n).collect())
}
/// All distinct EP names **visible to an app** — declared at the app or any
/// ancestor group, walking `apps.group_id → groups.parent_id`. Used by the
/// no-provider plan check and the read-only `extension-points ls --app`.
pub async fn list_on_app_chain(pool: &PgPool, app_id: AppId) -> Result<Vec<String>, sqlx::Error> {
let rows: Vec<(String,)> = sqlx::query_as(&format!(
"{CHAIN_LEVELS_CTE} \
SELECT DISTINCT e.name FROM extension_points e \
JOIN chain c ON (e.app_id = c.app_owner OR e.group_id = c.group_owner) \
ORDER BY e.name",
))
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(n,)| n).collect())
}
/// Insert an EP marker at `owner`, in the apply transaction. Idempotent: a
/// re-apply of an already-declared name is a no-op (`ON CONFLICT DO NOTHING`),
/// so the marker survives without a spurious version bump.