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:
@@ -123,6 +123,19 @@ impl Client {
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/extension-points` — the app's OWN
|
||||
/// extension-point declarations (for `pic pull`).
|
||||
pub async fn extension_points_list(&self, ident: &str) -> Result<Vec<ExtPointDto>> {
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{}/extension-points", seg(ident)),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/scripts` — every script the caller can see
|
||||
/// (server filters by membership for `Member`). Lets `pic scripts ls`
|
||||
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
|
||||
@@ -1320,6 +1333,8 @@ pub struct PlanDto {
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ChangeDto>,
|
||||
/// Fingerprint of the live state this plan was computed against; carried
|
||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||
#[serde(default)]
|
||||
@@ -1334,6 +1349,14 @@ pub struct ChangeDto {
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// One of an owner's OWN extension-point declarations (`pic pull`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExtPointDto {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined
|
||||
/// bound-plan token for the whole subtree.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -1358,6 +1381,8 @@ pub struct NodePlanDto {
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ChangeDto>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../apply`: counts of what changed.
|
||||
@@ -1386,6 +1411,12 @@ pub struct ApplyReportDto {
|
||||
#[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(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,15 @@ pub async fn run(
|
||||
"+{} ~{} -{}",
|
||||
report.vars_created, report.vars_updated, report.vars_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"extension-points",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.extension_points_created,
|
||||
report.extension_points_updated,
|
||||
report.extension_points_deleted
|
||||
),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
@@ -151,6 +160,15 @@ pub async fn run_tree(
|
||||
"+{} ~{} -{}",
|
||||
report.vars_created, report.vars_updated, report.vars_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"extension-points",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.extension_points_created,
|
||||
report.extension_points_updated,
|
||||
report.extension_points_deleted
|
||||
),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
|
||||
@@ -139,6 +139,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
triggers: crate::manifest::ManifestTriggers::default(),
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,12 +64,13 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
||||
for n in &plan.nodes {
|
||||
let node = format!("{}:{}", n.kind, n.slug);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 5] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 6] = [
|
||||
("script", &n.scripts),
|
||||
("route", &n.routes),
|
||||
("trigger", &n.triggers),
|
||||
("secret", &n.secrets),
|
||||
("var", &n.vars),
|
||||
("extension-point", &n.extension_points),
|
||||
];
|
||||
for (rk, changes) in groups {
|
||||
for c in changes {
|
||||
@@ -154,12 +155,28 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
);
|
||||
}
|
||||
|
||||
// Extension points: name + optional default module. The server's
|
||||
// `ExtPointSpec` deserializes this shape directly.
|
||||
let extension_points: Vec<Value> = manifest
|
||||
.extension_points
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut o = Map::new();
|
||||
o.insert("name".into(), json!(e.name));
|
||||
if let Some(d) = &e.default {
|
||||
o.insert("default".into(), json!(d));
|
||||
}
|
||||
Value::Object(o)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(json!({
|
||||
"scripts": scripts,
|
||||
"routes": routes,
|
||||
"triggers": triggers,
|
||||
"secrets": manifest.secrets.names,
|
||||
"vars": vars,
|
||||
"extension_points": extension_points,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -174,12 +191,13 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
||||
|
||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 5] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 6] = [
|
||||
("script", &plan.scripts),
|
||||
("route", &plan.routes),
|
||||
("trigger", &plan.triggers),
|
||||
("secret", &plan.secrets),
|
||||
("var", &plan.vars),
|
||||
("extension-point", &plan.extension_points),
|
||||
];
|
||||
for (kind, changes) in groups {
|
||||
for c in changes {
|
||||
|
||||
@@ -18,8 +18,8 @@ use crate::client::{Client, VarOwnerArg};
|
||||
use crate::config;
|
||||
use crate::manifest::{
|
||||
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
||||
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec,
|
||||
QueueTriggerSpec, MANIFEST_FILE,
|
||||
ManifestExtensionPoint, ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers,
|
||||
PubsubTriggerSpec, QueueTriggerSpec, MANIFEST_FILE,
|
||||
};
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
@@ -210,6 +210,18 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
|
||||
}
|
||||
|
||||
// Extension points (§5.5): the app's own declarations, exported so a
|
||||
// re-applied pulled manifest doesn't prune them.
|
||||
let extension_points: Vec<ManifestExtensionPoint> = client
|
||||
.extension_points_list(app_ident)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|e| ManifestExtensionPoint {
|
||||
name: e.name,
|
||||
default: e.default,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let manifest = Manifest {
|
||||
app: Some(ManifestApp {
|
||||
slug: app.app.slug.clone(),
|
||||
@@ -224,6 +236,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||
},
|
||||
vars: manifest_vars,
|
||||
extension_points,
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
|
||||
@@ -49,6 +49,10 @@ pub struct Manifest {
|
||||
/// The overlay merges per-env, last-write-wins by key.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub vars: BTreeMap<String, toml::Value>,
|
||||
/// `[[extension_points]]` — module names this node opens for per-tenant
|
||||
/// resolution (§5.5). Allowed on both app and group manifests.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extension_points: Vec<ManifestExtensionPoint>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
@@ -397,6 +401,17 @@ impl ManifestSecrets {
|
||||
}
|
||||
}
|
||||
|
||||
/// `[[extension_points]]` — a module name opened for per-tenant resolution
|
||||
/// (§5.5). A parent/group script importing `name` resolves it against the
|
||||
/// inheriting app's module instead of the sealed lexical chain; `default`
|
||||
/// names a local module used when an app provides none.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestExtensionPoint {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
// ---- serde skip/default helpers ----
|
||||
|
||||
fn is_endpoint(kind: &ScriptKind) -> bool {
|
||||
@@ -488,6 +503,10 @@ mod tests {
|
||||
("region".to_string(), toml::Value::String("eu".into())),
|
||||
("max-retries".to_string(), toml::Value::Integer(3)),
|
||||
]),
|
||||
extension_points: vec![ManifestExtensionPoint {
|
||||
name: "theme".into(),
|
||||
default: Some("default-theme".into()),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,12 +610,14 @@ mod tests {
|
||||
triggers: ManifestTriggers::default(),
|
||||
secrets: ManifestSecrets::default(),
|
||||
vars: BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
};
|
||||
let text = m.to_toml().unwrap();
|
||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||
assert!(!text.contains("triggers"), "got:\n{text}");
|
||||
assert!(!text.contains("secrets"), "got:\n{text}");
|
||||
assert!(!text.contains("vars"), "got:\n{text}");
|
||||
assert!(!text.contains("extension_points"), "got:\n{text}");
|
||||
// Still round-trips.
|
||||
assert_eq!(m, Manifest::parse(&text).unwrap());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user