diff --git a/crates/manager-core/migrations/0051_extension_points.sql b/crates/manager-core/migrations/0051_extension_points.sql new file mode 100644 index 0000000..d3ef962 --- /dev/null +++ b/crates/manager-core/migrations/0051_extension_points.sql @@ -0,0 +1,40 @@ +-- §5.5 (v1.2 Hierarchies): extension-point markers — opt-in module polymorphism. +-- +-- An extension point marks a module NAME at a node (app or group) as one +-- descendants are *expected* to provide or override. Imports of an EP name +-- resolve DYNAMICALLY against the inheriting app's view (the app can supply +-- its own module), instead of the Phase 4b lexical default (sealed to the +-- importing script's defining node). See docs §5.5. +-- +-- This table holds only the MARKER (owner, name) — it is pure declaration, +-- structurally identical to a `secrets` name. The optional DEFAULT BODY is +-- just a co-located `kind = 'module'` script of the same name at this node +-- (Phase 4b already stores/resolves/caches those); there is no body column +-- here. So a marker is config, not code → ON DELETE CASCADE (unlike the +-- module body's RESTRICT in 0050). +-- +-- Ownership is polymorphic (mirrors vars/secrets/scripts): exactly one of +-- (app_id, group_id) is set, with per-owner partial-unique LOWER(name) +-- indexes for case-insensitive uniqueness. + +CREATE TABLE extension_points ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + group_id UUID REFERENCES groups(id) ON DELETE CASCADE, + app_id UUID REFERENCES apps(id) ON DELETE CASCADE, + CONSTRAINT extension_points_owner_exactly_one + CHECK ((group_id IS NULL) <> (app_id IS NULL)), + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- One marker per (owner, name), case-insensitive. Partial because the owner +-- is split across two nullable columns. +CREATE UNIQUE INDEX extension_points_group_uidx + ON extension_points (group_id, LOWER(name)) WHERE group_id IS NOT NULL; +CREATE UNIQUE INDEX extension_points_app_uidx + ON extension_points (app_id, LOWER(name)) WHERE app_id IS NOT NULL; + +-- Lookup indexes for the resolver's chain join + list-by-owner. +CREATE INDEX extension_points_group_id_idx ON extension_points (group_id) WHERE group_id IS NOT NULL; +CREATE INDEX extension_points_app_id_idx ON extension_points (app_id) WHERE app_id IS NOT NULL; diff --git a/crates/manager-core/src/extension_point_repo.rs b/crates/manager-core/src/extension_point_repo.rs new file mode 100644 index 0000000..21f9a10 --- /dev/null +++ b/crates/manager-core/src/extension_point_repo.rs @@ -0,0 +1,99 @@ +//! 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::ScriptOwner; +use sqlx::{PgPool, Postgres, Transaction}; + +/// 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()) +} + +/// 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(()) +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 29108e3..26341a0 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -44,6 +44,7 @@ pub mod docs_repo; pub mod docs_service; pub mod email_inbound_api; pub mod email_service; +pub mod extension_point_repo; pub mod files_api; pub mod files_repo; pub mod files_service;