- 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>
118 lines
4.3 KiB
Rust
118 lines
4.3 KiB
Rust
//! Extension-point markers (§5.5) — the `extension_points` table (0051).
|
|
//!
|
|
//! An extension point is a pure marker `(owner, name)` declaring that a module
|
|
//! name is one descendants may provide/override (the import resolver then
|
|
//! resolves it dynamically against the inheriting app — see
|
|
//! [`crate::module_source`]). This module holds the read + transactional-write
|
|
//! helpers; it mirrors the var/secret tx-function style (free functions over a
|
|
//! `&PgPool` / `&mut Transaction`), keyed by the 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`.
|
|
pub async fn list_for_owner(pool: &PgPool, owner: ScriptOwner) -> Result<Vec<String>, sqlx::Error> {
|
|
let rows: Vec<(String,)> = match owner {
|
|
ScriptOwner::App(a) => {
|
|
sqlx::query_as(
|
|
"SELECT name FROM extension_points WHERE app_id = $1 ORDER BY LOWER(name)",
|
|
)
|
|
.bind(a.into_inner())
|
|
.fetch_all(pool)
|
|
.await?
|
|
}
|
|
ScriptOwner::Group(g) => {
|
|
sqlx::query_as(
|
|
"SELECT name FROM extension_points WHERE group_id = $1 ORDER BY LOWER(name)",
|
|
)
|
|
.bind(g.into_inner())
|
|
.fetch_all(pool)
|
|
.await?
|
|
}
|
|
};
|
|
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.
|
|
pub async fn insert_extension_point_tx(
|
|
tx: &mut Transaction<'_, Postgres>,
|
|
owner: ScriptOwner,
|
|
name: &str,
|
|
) -> Result<(), sqlx::Error> {
|
|
match owner {
|
|
ScriptOwner::App(a) => {
|
|
sqlx::query(
|
|
"INSERT INTO extension_points (app_id, name) VALUES ($1, $2) \
|
|
ON CONFLICT (app_id, LOWER(name)) WHERE app_id IS NOT NULL DO NOTHING",
|
|
)
|
|
.bind(a.into_inner())
|
|
.bind(name)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
}
|
|
ScriptOwner::Group(g) => {
|
|
sqlx::query(
|
|
"INSERT INTO extension_points (group_id, name) VALUES ($1, $2) \
|
|
ON CONFLICT (group_id, LOWER(name)) WHERE group_id IS NOT NULL DO NOTHING",
|
|
)
|
|
.bind(g.into_inner())
|
|
.bind(name)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete an EP marker at `owner` (case-insensitive), in the apply transaction.
|
|
/// Used by `--prune` when the manifest stops declaring a name.
|
|
pub async fn delete_extension_point_tx(
|
|
tx: &mut Transaction<'_, Postgres>,
|
|
owner: ScriptOwner,
|
|
name: &str,
|
|
) -> Result<(), sqlx::Error> {
|
|
match owner {
|
|
ScriptOwner::App(a) => {
|
|
sqlx::query(
|
|
"DELETE FROM extension_points WHERE app_id = $1 AND LOWER(name) = LOWER($2)",
|
|
)
|
|
.bind(a.into_inner())
|
|
.bind(name)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
}
|
|
ScriptOwner::Group(g) => {
|
|
sqlx::query(
|
|
"DELETE FROM extension_points WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
|
|
)
|
|
.bind(g.into_inner())
|
|
.bind(name)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|