feat(modules): extension-point marker schema + repo (§5.5 C1)
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) <noreply@anthropic.com>
This commit is contained in:
40
crates/manager-core/migrations/0051_extension_points.sql
Normal file
40
crates/manager-core/migrations/0051_extension_points.sql
Normal file
@@ -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;
|
||||
99
crates/manager-core/src/extension_point_repo.rs
Normal file
99
crates/manager-core/src/extension_point_repo.rs
Normal file
@@ -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<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())
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user