//! 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, 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, 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(()) }