From 8b68a7d7e8e8007cf5c85b8dbe50757f007de635 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 29 Jun 2026 20:02:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(modules):=20extension-point=20marker=20sch?= =?UTF-8?q?ema=20+=20repo=20(=C2=A75.5=20C1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An extension point (§5.5) marks a module name a node offers for descendants to provide/override — resolved dynamically against the inheriting app rather than lexically sealed. Lay the storage: - migration 0051: owner-polymorphic `extension_points(id, group_id?, app_id?, name)` marker table — exactly-one CHECK + per-owner partial-unique LOWER(name) indexes + lookup indexes (mirrors 0050). ON DELETE CASCADE (a marker is config, not code). No default-body column — the optional default body is a co-located kind=module script (Phase 4b stores/resolves/caches those). - extension_point_repo: `list_for_owner` + idempotent `insert_extension_point_tx` / `delete_extension_point_tx`, keyed by the shared `ScriptOwner` (mirrors the var/secret tx-fn style). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migrations/0051_extension_points.sql | 40 ++++++++ .../manager-core/src/extension_point_repo.rs | 99 +++++++++++++++++++ crates/manager-core/src/lib.rs | 1 + 3 files changed, 140 insertions(+) create mode 100644 crates/manager-core/migrations/0051_extension_points.sql create mode 100644 crates/manager-core/src/extension_point_repo.rs 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;