feat(hierarchies): extension points — opt-in dynamic module resolution (§5.5)
M1 of the remaining-hierarchies work. An extension point lets a PARENT/group
node opt a module name out of sealed lexical resolution: a parent script's
`import "x"` then resolves against the INHERITING app's module (with a declared
default body as fallback) instead of the parent's sealed chain — so each
descendant app supplies its own `x`. Only the declaring parent can open the
inversion; a descendant can never make a parent's sealed import dynamic or
hijack one (the "is this an extension point" decision keys on the importer's
trusted defining node, never a script-passed value).
Resolver (executor-core):
- `ModuleSource` gains `resolve_extension_point(origin, name)` and a
chain-constrained `resolve_by_id(origin, id)`. The resolve seam checks for an
ext point on the importer's chain first; on a hit it resolves against
`App(cx.app_id)`, else the declared default, else ModuleNotFound. The Postgres
query returns Some only when the NEAREST declaration of the name is an ext
point (a peer/nearer concrete module shadows it). A group origin can't reach
app-owned rows — the trust boundary holds. Cache stays id-keyed (no bleed).
Apply (manager-core, mirrors the `vars` pattern):
- migration 0051: owner-polymorphic `extension_points` table (group XOR app),
optional `default_script_id` FK ON DELETE SET NULL, per-owner LOWER(name)
unique indexes.
- Bundle/Plan/CurrentState gain extension_points; `diff_extension_points`
(name-based, default change = Update), reconcile (upsert after scripts so the
default resolves) + prune, `validate_bundle` (module-name shape; default must
be a local module), `check_imports_resolve` (a declared/on-chain ext point
satisfies an import), and the state_token fold. Writes gate on the
script-write capability (AppWriteScript / GroupScriptsWrite).
- GET /apps|groups/{id}/extension-points so `pic pull` round-trips them — a
re-applied pulled manifest is all-NoOp, so `--prune` can't silently drop one.
CLI: `[[extension_points]]` manifest table; plan/apply build + render; pull
exports them.
Tests: 5 resolver units (inversion, default fallback, no-provider error,
no-hijack of a sealed import, no cross-tenant cache bleed), 2 diff/state-token
units, 2 journeys (per-app resolution + default fallback via invoke; pull
round-trip). Schema golden re-blessed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
routing::{get, post},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{AppId, GroupId, Principal};
|
||||
@@ -17,8 +17,8 @@ use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, NodeKind, PlanResult,
|
||||
TreeBundle, TreePlanResult,
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, ExtPointView,
|
||||
NodeKind, PlanResult, TreeBundle, TreePlanResult,
|
||||
};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
@@ -30,11 +30,53 @@ pub fn apply_router(service: ApplyService) -> Router {
|
||||
.route("/apps/{id}/apply", post(apply_handler))
|
||||
.route("/groups/{id}/plan", post(group_plan_handler))
|
||||
.route("/groups/{id}/apply", post(group_apply_handler))
|
||||
.route("/apps/{id}/extension-points", get(app_ext_points_handler))
|
||||
.route(
|
||||
"/groups/{id}/extension-points",
|
||||
get(group_ext_points_handler),
|
||||
)
|
||||
.route("/tree/plan", post(tree_plan_handler))
|
||||
.route("/tree/apply", post(tree_apply_handler))
|
||||
.with_state(service)
|
||||
}
|
||||
|
||||
/// `GET .../apps/{id}/extension-points` — list the app's OWN extension-point
|
||||
/// declarations (for `pic pull`). Read-only; viewer-tier (`AppRead`).
|
||||
async fn app_ext_points_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
Ok(Json(
|
||||
svc.list_extension_points(ApplyOwner::App(app_id)).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// `GET .../groups/{id}/extension-points` — list the group's OWN extension-point
|
||||
/// declarations. Read-only; viewer-tier (`GroupScriptsRead`).
|
||||
async fn group_ext_points_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
Ok(Json(
|
||||
svc.list_extension_points(ApplyOwner::Group(group_id))
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ApplyRequest {
|
||||
pub bundle: Bundle,
|
||||
@@ -232,7 +274,11 @@ async fn require_app_node_writes(
|
||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
if prune || !bundle.scripts.is_empty() {
|
||||
// Extension points are module-resolution declarations, so they gate on the
|
||||
// same script-write tier as modules — without this, an ext-point-only
|
||||
// bundle (no `default`, so no script) would pass with viewer-tier `AppRead`
|
||||
// and let a reader open the §5.5 import trust boundary.
|
||||
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
@@ -292,7 +338,8 @@ async fn require_group_node_writes(
|
||||
bundle: &Bundle,
|
||||
prune: bool,
|
||||
) -> Result<(), ApplyError> {
|
||||
if prune || !bundle.scripts.is_empty() {
|
||||
// Extension points gate on the script-write tier (see the app variant).
|
||||
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
|
||||
@@ -83,6 +83,32 @@ pub struct Bundle {
|
||||
/// not expressible here; use `pic vars set --tombstone`.
|
||||
#[serde(default)]
|
||||
pub vars: std::collections::BTreeMap<String, serde_json::Value>,
|
||||
/// Declared extension points (§5.5): module names this node opens for
|
||||
/// per-tenant resolution by inheriting apps. Reconciled at the node that
|
||||
/// owns the importing script.
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ExtPointSpec>,
|
||||
}
|
||||
|
||||
/// Desired extension point — a module name opened for dynamic, per-tenant
|
||||
/// resolution, with an optional default body (a module declared at this node).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ExtPointSpec {
|
||||
pub name: String,
|
||||
/// Name of a module declared at this node to use when an inheriting app
|
||||
/// provides none. Omitted ⇒ no fallback (a non-providing app errors at
|
||||
/// import time).
|
||||
#[serde(default)]
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
/// One of a node's OWN extension-point declarations, for the read/list
|
||||
/// surface (`pic pull`). The default is its module NAME (not the raw id).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ExtPointView {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -315,6 +341,8 @@ pub struct Plan {
|
||||
pub secrets: Vec<ResourceChange>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ResourceChange>,
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ResourceChange>,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
@@ -327,6 +355,7 @@ impl Plan {
|
||||
.chain(&self.triggers)
|
||||
.chain(&self.secrets)
|
||||
.chain(&self.vars)
|
||||
.chain(&self.extension_points)
|
||||
.all(|c| c.op == Op::NoOp)
|
||||
}
|
||||
}
|
||||
@@ -403,6 +432,10 @@ pub struct CurrentState {
|
||||
/// the manifest reconciles. Inherited group vars and tombstones are
|
||||
/// excluded (the manifest manages only the app's own real values).
|
||||
pub vars: Vec<(String, serde_json::Value)>,
|
||||
/// The owner's OWN extension-point declarations: `(name, default module
|
||||
/// name)`. The default is resolved back to its module name (not the raw
|
||||
/// id) so the diff is name-based and stable across re-applies.
|
||||
pub extension_points: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -773,6 +806,43 @@ impl ApplyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. Extension points (§5.5) — reconcile after scripts so a `default`
|
||||
// module name resolves to its just-reconciled id. Upsert every
|
||||
// Create/Update; the default (if named) must be a local module.
|
||||
let bundle_ext: HashMap<String, &ExtPointSpec> = bundle
|
||||
.extension_points
|
||||
.iter()
|
||||
.map(|e| (e.name.to_lowercase(), e))
|
||||
.collect();
|
||||
for ch in &plan.extension_points {
|
||||
match ch.op {
|
||||
Op::Create | Op::Update => {
|
||||
let spec = bundle_ext[&ch.key.to_lowercase()];
|
||||
let default_id = match &spec.default {
|
||||
Some(name) => {
|
||||
Some(*name_to_id.get(&name.to_lowercase()).ok_or_else(|| {
|
||||
ApplyError::Invalid(format!(
|
||||
"extension point `{}` default `{name}` is not a \
|
||||
module declared here",
|
||||
spec.name
|
||||
))
|
||||
})?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
owner
|
||||
.set_ext_point_tx(&mut *tx, &spec.name, default_id)
|
||||
.await?;
|
||||
if ch.op == Op::Create {
|
||||
report.extension_points_created += 1;
|
||||
} else {
|
||||
report.extension_points_updated += 1;
|
||||
}
|
||||
}
|
||||
Op::NoOp | Op::Delete => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Prune (only with --prune): delete stale triggers, then scripts
|
||||
// (route removals already happened in the route delete-pass above).
|
||||
// Secret pruning is deliberately deferred (destructive + irreversible):
|
||||
@@ -848,6 +918,15 @@ impl ApplyService {
|
||||
report.vars_deleted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Extension points are prunable declarations (like vars): a live
|
||||
// declaration dropped from the manifest is removed.
|
||||
for ch in &plan.extension_points {
|
||||
if ch.op == Op::Delete {
|
||||
owner.delete_ext_point_tx(&mut *tx, &ch.key).await?;
|
||||
report.extension_points_deleted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(name_to_id)
|
||||
}
|
||||
@@ -1570,6 +1649,13 @@ impl ApplyService {
|
||||
.filter(|s| s.kind == ScriptKind::Module)
|
||||
.map(|s| s.name.to_lowercase())
|
||||
.collect();
|
||||
// Extension points declared in THIS bundle also satisfy imports — the
|
||||
// concrete body is supplied by an inheriting app at runtime (§5.5).
|
||||
let local_ext: HashSet<String> = bundle
|
||||
.extension_points
|
||||
.iter()
|
||||
.map(|e| e.name.to_lowercase())
|
||||
.collect();
|
||||
let origin = match owner {
|
||||
ApplyOwner::App(a) => picloud_shared::ScriptOwner::App(a),
|
||||
ApplyOwner::Group(g) => picloud_shared::ScriptOwner::Group(g),
|
||||
@@ -1579,7 +1665,7 @@ impl ApplyService {
|
||||
for s in &bundle.scripts {
|
||||
for imp in self.script_imports(s)? {
|
||||
let key = imp.to_lowercase();
|
||||
if local.contains(&key) || !checked.insert(key) {
|
||||
if local.contains(&key) || local_ext.contains(&key) || !checked.insert(key) {
|
||||
continue;
|
||||
}
|
||||
let found = self
|
||||
@@ -1587,7 +1673,18 @@ impl ApplyService {
|
||||
.resolve(origin, &imp)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
if found.is_none() {
|
||||
if found.is_some() {
|
||||
continue;
|
||||
}
|
||||
// Not a concrete module — but an extension point declared on an
|
||||
// ancestor also satisfies the import (resolved per-app at run
|
||||
// time). Only a name that is neither is a dangling import.
|
||||
let ep = self
|
||||
.modules
|
||||
.resolve_extension_point(origin, &imp)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
if ep.is_none() {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"script `{}` imports unknown module `{imp}` \
|
||||
(not declared here and not inherited from an ancestor)",
|
||||
@@ -1603,6 +1700,7 @@ impl ApplyService {
|
||||
/// scripts reachable from the app's chain that a route/trigger may bind to
|
||||
/// even though the manifest doesn't declare them (Phase 4). They count as
|
||||
/// known endpoints for the binding checks below.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn validate_bundle(
|
||||
&self,
|
||||
bundle: &Bundle,
|
||||
@@ -1722,6 +1820,33 @@ impl ApplyService {
|
||||
for key in bundle.vars.keys() {
|
||||
validate_var_key(key)?;
|
||||
}
|
||||
|
||||
// Extension points (§5.5): unique kebab names; a `default` must name a
|
||||
// module declared in this node (an endpoint is never importable).
|
||||
let module_names: HashSet<String> = bundle
|
||||
.scripts
|
||||
.iter()
|
||||
.filter(|s| s.kind == ScriptKind::Module)
|
||||
.map(|s| s.name.to_lowercase())
|
||||
.collect();
|
||||
let mut ext_names: HashSet<String> = HashSet::new();
|
||||
for e in &bundle.extension_points {
|
||||
validate_ext_point_name(&e.name)?;
|
||||
if !ext_names.insert(e.name.to_lowercase()) {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"duplicate extension point `{}`",
|
||||
e.name
|
||||
)));
|
||||
}
|
||||
if let Some(default) = &e.default {
|
||||
if !module_names.contains(&default.to_lowercase()) {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"extension point `{}` default `{default}` is not a module declared here",
|
||||
e.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1769,15 +1894,58 @@ impl ApplyService {
|
||||
.filter(|r| r.environment_scope == "*" && !r.is_tombstone)
|
||||
.map(|r| (r.key, r.value))
|
||||
.collect();
|
||||
let extension_points = self.load_extension_points(owner).await?;
|
||||
Ok(CurrentState {
|
||||
scripts,
|
||||
routes,
|
||||
triggers,
|
||||
secret_names,
|
||||
vars,
|
||||
extension_points,
|
||||
})
|
||||
}
|
||||
|
||||
/// The owner's OWN extension-point declarations, each with its default
|
||||
/// module's NAME (resolved via the FK) so the diff is name-based. Read
|
||||
/// directly off the pool (no repo trait — the pure `compute_diff` tests
|
||||
/// bypass `load_current`, and this is integration-tested).
|
||||
async fn load_extension_points(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
) -> Result<Vec<(String, Option<String>)>, ApplyError> {
|
||||
let (col, val) = match owner {
|
||||
ApplyOwner::App(a) => ("app_id", a.into_inner()),
|
||||
ApplyOwner::Group(g) => ("group_id", g.into_inner()),
|
||||
};
|
||||
let rows: Vec<(String, Option<String>)> = sqlx::query_as(&format!(
|
||||
"SELECT e.name, ds.name AS default_name \
|
||||
FROM extension_points e \
|
||||
LEFT JOIN scripts ds ON ds.id = e.default_script_id \
|
||||
WHERE e.{col} = $1 ORDER BY LOWER(e.name)"
|
||||
))
|
||||
.bind(val)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The owner's OWN extension-point declarations, for the `pic pull` export.
|
||||
///
|
||||
/// # Errors
|
||||
/// `Backend` on a repo failure.
|
||||
pub async fn list_extension_points(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
) -> Result<Vec<ExtPointView>, ApplyError> {
|
||||
Ok(self
|
||||
.load_extension_points(owner)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(name, default)| ExtPointView { name, default })
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn all_secret_names(&self, app_id: AppId) -> Result<Vec<String>, ApplyError> {
|
||||
let mut names = Vec::new();
|
||||
let mut cursor: Option<String> = None;
|
||||
@@ -1838,6 +2006,66 @@ fn compute_diff_with_names(
|
||||
triggers: diff_triggers(current, bundle, &script_name_by_id),
|
||||
secrets: diff_secrets(current, bundle),
|
||||
vars: diff_vars(current, bundle),
|
||||
extension_points: diff_extension_points(current, bundle),
|
||||
}
|
||||
}
|
||||
|
||||
/// Diff extension points by name. The "value" is the default module name, so a
|
||||
/// changed default is an `Update` (like vars, the declaration lives in the
|
||||
/// manifest). Live declarations absent from the manifest are `Delete` (applied
|
||||
/// only under `--prune`). Name comparison is case-insensitive to match the
|
||||
/// per-owner `LOWER(name)` unique index.
|
||||
fn diff_extension_points(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||||
let live: HashMap<String, Option<&str>> = current
|
||||
.extension_points
|
||||
.iter()
|
||||
.map(|(name, default)| (name.to_lowercase(), default.as_deref()))
|
||||
.collect();
|
||||
let desired: HashSet<String> = bundle
|
||||
.extension_points
|
||||
.iter()
|
||||
.map(|e| e.name.to_lowercase())
|
||||
.collect();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for e in &bundle.extension_points {
|
||||
let want = e.default.as_deref();
|
||||
match live.get(&e.name.to_lowercase()) {
|
||||
Some(cur) if eq_ci_opt(*cur, want) => out.push(ResourceChange {
|
||||
op: Op::NoOp,
|
||||
key: e.name.clone(),
|
||||
detail: None,
|
||||
}),
|
||||
Some(_) => out.push(ResourceChange {
|
||||
op: Op::Update,
|
||||
key: e.name.clone(),
|
||||
detail: Some("default changed".into()),
|
||||
}),
|
||||
None => out.push(ResourceChange {
|
||||
op: Op::Create,
|
||||
key: e.name.clone(),
|
||||
detail: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
for (name, _) in ¤t.extension_points {
|
||||
if !desired.contains(&name.to_lowercase()) {
|
||||
out.push(ResourceChange {
|
||||
op: Op::Delete,
|
||||
key: name.clone(),
|
||||
detail: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Case-insensitive equality for two optional module names.
|
||||
fn eq_ci_opt(a: Option<&str>, b: Option<&str>) -> bool {
|
||||
match (a, b) {
|
||||
(Some(x), Some(y)) => x.eq_ignore_ascii_case(y),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1883,6 +2111,28 @@ fn diff_vars(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||||
|
||||
/// Kebab var key (`^[a-z0-9][a-z0-9-]{0,127}$`) — mirrors `vars_api`'s
|
||||
/// `validate_key` so the manifest and the interactive surface agree.
|
||||
/// Extension-point name rule: the SAME identifier shape as a module name
|
||||
/// (`^[a-zA-Z_][a-zA-Z0-9_]{0,63}$`, migration 0015). An extension point is
|
||||
/// resolved by `import "<name>"`, and the concrete module that satisfies it
|
||||
/// must be a real module script — whose name is identifier-shaped — so a
|
||||
/// hyphenated extension point could never be provided. Reject early.
|
||||
fn validate_ext_point_name(name: &str) -> Result<(), ApplyError> {
|
||||
let mut chars = name.chars();
|
||||
let ok = name.len() <= 64
|
||||
&& chars
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApplyError::Invalid(format!(
|
||||
"extension point name `{name}` must be 1–64 chars of letters, digits, and \
|
||||
underscores, starting with a letter or underscore (a module-name shape)"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_var_key(key: &str) -> Result<(), ApplyError> {
|
||||
let ok = !key.is_empty()
|
||||
&& key.len() <= 128
|
||||
@@ -2034,6 +2284,55 @@ impl ApplyOwner {
|
||||
Self::Group(g) => delete_group_var_tx(tx, g, key).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_ext_point_tx(
|
||||
self,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
name: &str,
|
||||
default_script_id: Option<ScriptId>,
|
||||
) -> Result<(), ApplyError> {
|
||||
let default = default_script_id.map(ScriptId::into_inner);
|
||||
let (col, val) = match self {
|
||||
Self::App(a) => ("app_id", a.into_inner()),
|
||||
Self::Group(g) => ("group_id", g.into_inner()),
|
||||
};
|
||||
// Owner-kind-specific upsert; the conflict target is the matching
|
||||
// partial-unique index, so its predicate is restated. Update both the
|
||||
// default and updated_at so a re-applied default change takes effect.
|
||||
sqlx::query(&format!(
|
||||
"INSERT INTO extension_points ({col}, name, default_script_id) \
|
||||
VALUES ($1, $2, $3) \
|
||||
ON CONFLICT ({col}, LOWER(name)) WHERE {col} IS NOT NULL DO UPDATE \
|
||||
SET default_script_id = EXCLUDED.default_script_id, updated_at = NOW()"
|
||||
))
|
||||
.bind(val)
|
||||
.bind(name)
|
||||
.bind(default)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_ext_point_tx(
|
||||
self,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
name: &str,
|
||||
) -> Result<(), ApplyError> {
|
||||
let (col, val) = match self {
|
||||
Self::App(a) => ("app_id", a.into_inner()),
|
||||
Self::Group(g) => ("group_id", g.into_inner()),
|
||||
};
|
||||
sqlx::query(&format!(
|
||||
"DELETE FROM extension_points WHERE {col} = $1 AND LOWER(name) = LOWER($2)"
|
||||
))
|
||||
.bind(val)
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_scripts(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||||
@@ -2594,6 +2893,12 @@ pub struct ApplyReport {
|
||||
pub vars_updated: u32,
|
||||
#[serde(default)]
|
||||
pub vars_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_created: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_updated: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_deleted: u32,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -2818,6 +3123,15 @@ fn state_token_with_names(
|
||||
serde_json::to_string(value).unwrap_or_default()
|
||||
));
|
||||
}
|
||||
// Extension points: name + default module name. A changed default moves
|
||||
// the token so the bound-plan check catches an edit between plan and apply.
|
||||
for (name, default) in ¤t.extension_points {
|
||||
parts.push(format!(
|
||||
"e|{}|{}",
|
||||
name.to_lowercase(),
|
||||
default.as_deref().unwrap_or("")
|
||||
));
|
||||
}
|
||||
// Order-independent: sort the per-resource tokens before hashing.
|
||||
parts.sort_unstable();
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
@@ -2898,6 +3212,7 @@ mod tests {
|
||||
triggers: vec![],
|
||||
secrets: vec!["S".into()],
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert_eq!(plan.scripts.len(), 1);
|
||||
@@ -2920,6 +3235,7 @@ mod tests {
|
||||
triggers: vec![],
|
||||
secrets: vec!["S".into()],
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
|
||||
@@ -2937,6 +3253,7 @@ mod tests {
|
||||
triggers: vec![],
|
||||
secrets: vec![],
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert_eq!(plan.scripts[0].op, Op::Update);
|
||||
@@ -2955,6 +3272,7 @@ mod tests {
|
||||
triggers: vec![],
|
||||
secrets: vec![],
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert_eq!(plan.scripts[0].op, Op::Delete);
|
||||
@@ -3001,6 +3319,7 @@ mod tests {
|
||||
triggers: vec![],
|
||||
secrets: vec![],
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert_eq!(plan.routes[0].op, Op::Update);
|
||||
@@ -3013,6 +3332,7 @@ mod tests {
|
||||
triggers: vec![],
|
||||
secrets: vec![],
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3259,6 +3579,70 @@ mod tests {
|
||||
assert_eq!(state_token(&ab), state_token(&ba));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_extension_points_create_update_noop_delete() {
|
||||
let current = CurrentState {
|
||||
extension_points: vec![
|
||||
("theme".into(), Some("dtheme".into())),
|
||||
("keep".into(), None),
|
||||
("stale".into(), None),
|
||||
],
|
||||
..CurrentState::default()
|
||||
};
|
||||
let bundle = Bundle {
|
||||
extension_points: vec![
|
||||
ExtPointSpec {
|
||||
name: "theme".into(),
|
||||
default: Some("other".into()),
|
||||
}, // default changed → Update
|
||||
ExtPointSpec {
|
||||
name: "Keep".into(),
|
||||
default: None,
|
||||
}, // identical (case-insensitive) → NoOp
|
||||
ExtPointSpec {
|
||||
name: "new".into(),
|
||||
default: None,
|
||||
}, // not live → Create
|
||||
// "stale" is live but undeclared → Delete.
|
||||
],
|
||||
..empty_bundle()
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
let op = |k: &str| {
|
||||
plan.extension_points
|
||||
.iter()
|
||||
.find(|c| c.key.eq_ignore_ascii_case(k))
|
||||
.unwrap_or_else(|| panic!("no change for {k}: {plan:?}"))
|
||||
.op
|
||||
};
|
||||
assert_eq!(op("theme"), Op::Update);
|
||||
assert_eq!(op("keep"), Op::NoOp);
|
||||
assert_eq!(op("new"), Op::Create);
|
||||
assert_eq!(op("stale"), Op::Delete);
|
||||
assert!(!plan.is_noop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_token_is_sensitive_to_extension_point_default() {
|
||||
let base = CurrentState {
|
||||
extension_points: vec![("theme".into(), Some("a".into()))],
|
||||
..CurrentState::default()
|
||||
};
|
||||
// A default change must flip the token (bound-plan check for an
|
||||
// out-of-band ext-point edit between plan and apply).
|
||||
let changed = CurrentState {
|
||||
extension_points: vec![("theme".into(), Some("b".into()))],
|
||||
..CurrentState::default()
|
||||
};
|
||||
assert_ne!(state_token(&base), state_token(&changed));
|
||||
// And adding/removing a declaration moves it too.
|
||||
let added = CurrentState {
|
||||
extension_points: vec![("theme".into(), Some("a".into())), ("extra".into(), None)],
|
||||
..CurrentState::default()
|
||||
};
|
||||
assert_ne!(state_token(&base), state_token(&added));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_token_ignores_dead_letter_triggers() {
|
||||
// The diff ignores dead-letter triggers (not manifest-representable),
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{ModuleScript, ModuleSource, ModuleSourceError, ScriptOwner};
|
||||
use picloud_shared::{
|
||||
ExtPointResolution, ModuleScript, ModuleSource, ModuleSourceError, ScriptId, ScriptOwner,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config_resolver::{CHAIN_LEVELS_CTE, GROUP_CHAIN_LEVELS_CTE};
|
||||
@@ -96,4 +98,92 @@ impl ModuleSource for PostgresModuleSource {
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ExtPointResolution>, ModuleSourceError> {
|
||||
// Find the nearest extension-point declaration of `name` on `origin`'s
|
||||
// chain that is NOT shadowed by a concrete module of the same name at
|
||||
// depth <= its own (tie → concrete wins). The NOT EXISTS encodes that
|
||||
// shadowing rule; ORDER BY depth ASC LIMIT 1 picks the nearest
|
||||
// surviving declaration. An App origin can see ext points declared on
|
||||
// an ancestor group (or on the app itself); a Group origin only walks
|
||||
// groups — same trust boundary as `resolve`.
|
||||
let (cte, ep_join, mod_join) = match origin {
|
||||
ScriptOwner::App(_) => (
|
||||
CHAIN_LEVELS_CTE,
|
||||
"(e.group_id = c.group_owner OR e.app_id = c.app_owner)",
|
||||
"(s.app_id = c2.app_owner OR s.group_id = c2.group_owner)",
|
||||
),
|
||||
ScriptOwner::Group(_) => (
|
||||
GROUP_CHAIN_LEVELS_CTE,
|
||||
"e.group_id = c.group_owner",
|
||||
"s.group_id = c2.group_owner",
|
||||
),
|
||||
};
|
||||
let query = format!(
|
||||
"{cte} \
|
||||
SELECT e.default_script_id \
|
||||
FROM extension_points e \
|
||||
JOIN chain c ON {ep_join} \
|
||||
WHERE LOWER(e.name) = LOWER($2) \
|
||||
AND NOT EXISTS ( \
|
||||
SELECT 1 FROM scripts s \
|
||||
JOIN chain c2 ON {mod_join} \
|
||||
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
|
||||
AND c2.depth <= c.depth \
|
||||
) \
|
||||
ORDER BY c.depth ASC LIMIT 1",
|
||||
);
|
||||
let root = match origin {
|
||||
ScriptOwner::App(a) => a.into_inner(),
|
||||
ScriptOwner::Group(g) => g.into_inner(),
|
||||
};
|
||||
let row: Option<(Option<uuid::Uuid>,)> = sqlx::query_as(&query)
|
||||
.bind(root)
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
Ok(row.map(|(default_script_id,)| ExtPointResolution {
|
||||
default_script_id: default_script_id.map(Into::into),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
// Load a module body by id (the ext-point fallback), but ONLY if it is
|
||||
// owned by a node on `origin`'s chain — the same trust boundary as
|
||||
// `resolve`. This keeps the resolver self-defending: a `default_script_id`
|
||||
// that ever pointed off-chain resolves to None instead of executing
|
||||
// cross-tenant code. Endpoints are never importable (kind filter).
|
||||
let (cte, join) = match origin {
|
||||
ScriptOwner::App(_) => (
|
||||
CHAIN_LEVELS_CTE,
|
||||
"(s.app_id = c.app_owner OR s.group_id = c.group_owner)",
|
||||
),
|
||||
ScriptOwner::Group(_) => (GROUP_CHAIN_LEVELS_CTE, "s.group_id = c.group_owner"),
|
||||
};
|
||||
let root = match origin {
|
||||
ScriptOwner::App(a) => a.into_inner(),
|
||||
ScriptOwner::Group(g) => g.into_inner(),
|
||||
};
|
||||
let row: Option<ModuleRow> = sqlx::query_as(&format!(
|
||||
"{cte} \
|
||||
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
|
||||
FROM scripts s JOIN chain c ON {join} \
|
||||
WHERE s.id = $2 AND s.kind = 'module' LIMIT 1"
|
||||
))
|
||||
.bind(root)
|
||||
.bind(script_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user