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:
MechaCat02
2026-06-29 20:02:22 +02:00
parent 4e25a142bd
commit 8b68a7d7e8
3 changed files with 140 additions and 0 deletions

View 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(())
}

View File

@@ -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;