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>
244 lines
8.5 KiB
Rust
244 lines
8.5 KiB
Rust
//! Extension points (§5.5) end-to-end via `pic apply` + `invoke`:
|
|
//! * a GROUP declares a module name `theme` an extension point (with a
|
|
//! default body) and a group endpoint `render` that imports it,
|
|
//! * an app under the group provides its OWN `theme`; invoking the inherited
|
|
//! `render` in that app's context resolves the APP's `theme` (the
|
|
//! inversion) — NOT sealed to the group,
|
|
//! * an app that provides no `theme` falls back to the group's default body,
|
|
//! * `pull` round-trips the declaration (a re-applied pulled manifest is a
|
|
//! no-op, so `--prune` never silently drops it).
|
|
|
|
use std::fs;
|
|
|
|
use tempfile::TempDir;
|
|
|
|
use crate::common;
|
|
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
|
|
|
|
fn manifest_dir() -> TempDir {
|
|
let dir = TempDir::new().expect("tempdir");
|
|
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
|
dir
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn extension_point_resolves_per_app_with_default_fallback() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::admin_env(fx);
|
|
let group = common::unique_slug("ep-grp");
|
|
let app_a = common::unique_slug("ep-a");
|
|
let app_b = common::unique_slug("ep-b");
|
|
|
|
// GroupGuard first so it drops LAST (after apps + group scripts).
|
|
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
|
common::pic_as(&env)
|
|
.args(["groups", "create", &group])
|
|
.assert()
|
|
.success();
|
|
|
|
let dir = manifest_dir();
|
|
|
|
// --- Group manifest: a default `theme` body, a `render` endpoint that
|
|
// imports `theme`, and the `[[extension_points]]` declaration. ---
|
|
fs::write(
|
|
dir.path().join("scripts/defaulttheme.rhai"),
|
|
r#"fn name() { "default" }"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
dir.path().join("scripts/render.rhai"),
|
|
r#"import "theme" as t; t::name()"#,
|
|
)
|
|
.unwrap();
|
|
let group_manifest = format!(
|
|
"[group]\nslug = \"{group}\"\nname = \"EP Group\"\n\n\
|
|
[[scripts]]\nname = \"defaulttheme\"\nfile = \"scripts/defaulttheme.rhai\"\nkind = \"module\"\n\n\
|
|
[[scripts]]\nname = \"render\"\nfile = \"scripts/render.rhai\"\n\n\
|
|
[[extension_points]]\nname = \"theme\"\ndefault = \"defaulttheme\"\n"
|
|
);
|
|
let group_path = dir.path().join("group.toml");
|
|
fs::write(&group_path, &group_manifest).unwrap();
|
|
let out = common::pic_as(&env)
|
|
.args(["apply", "--file"])
|
|
.arg(&group_path)
|
|
.output()
|
|
.expect("group apply");
|
|
assert!(
|
|
out.status.success(),
|
|
"group apply failed: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
// Group scripts block group deletion (RESTRICT) — guard them.
|
|
let _gr = ScriptGuard::new(
|
|
&env.url,
|
|
&env.token,
|
|
&group_script_id(&env, &group, "render"),
|
|
);
|
|
let _gd = ScriptGuard::new(
|
|
&env.url,
|
|
&env.token,
|
|
&group_script_id(&env, &group, "defaulttheme"),
|
|
);
|
|
|
|
// --- App A under the group: provides its OWN `theme` + a caller that
|
|
// invokes the inherited `render`. ---
|
|
let _ga = AppGuard::new(&env.url, &env.token, &app_a);
|
|
common::pic_as(&env)
|
|
.args(["apps", "create", &app_a, "--group", &group])
|
|
.assert()
|
|
.success();
|
|
fs::write(
|
|
dir.path().join("scripts/theme-a.rhai"),
|
|
r#"fn name() { "app-a" }"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
dir.path().join("scripts/caller.rhai"),
|
|
r#"invoke("render", #{})"#,
|
|
)
|
|
.unwrap();
|
|
let app_a_manifest = format!(
|
|
"[app]\nslug = \"{app_a}\"\nname = \"EP A\"\n\n\
|
|
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme-a.rhai\"\nkind = \"module\"\n\n\
|
|
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
|
|
);
|
|
let app_a_path = dir.path().join("a.toml");
|
|
fs::write(&app_a_path, &app_a_manifest).unwrap();
|
|
common::pic_as(&env)
|
|
.args(["apply", "--file"])
|
|
.arg(&app_a_path)
|
|
.assert()
|
|
.success();
|
|
|
|
// The inherited `render` imports the ext point `theme`; in App A's context
|
|
// it resolves App A's OWN `theme`.
|
|
let caller_a = app_script_id(&env, &app_a, "caller");
|
|
assert_eq!(
|
|
invoke_body(&env, &caller_a),
|
|
serde_json::json!("app-a"),
|
|
"extension point must resolve the inheriting app's module"
|
|
);
|
|
|
|
// --- App B under the group: provides NO `theme` → render falls back to the
|
|
// group's default body. ---
|
|
let _gb = AppGuard::new(&env.url, &env.token, &app_b);
|
|
common::pic_as(&env)
|
|
.args(["apps", "create", &app_b, "--group", &group])
|
|
.assert()
|
|
.success();
|
|
let app_b_manifest = format!(
|
|
"[app]\nslug = \"{app_b}\"\nname = \"EP B\"\n\n\
|
|
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
|
|
);
|
|
let app_b_path = dir.path().join("b.toml");
|
|
fs::write(&app_b_path, &app_b_manifest).unwrap();
|
|
common::pic_as(&env)
|
|
.args(["apply", "--file"])
|
|
.arg(&app_b_path)
|
|
.assert()
|
|
.success();
|
|
let caller_b = app_script_id(&env, &app_b, "caller");
|
|
assert_eq!(
|
|
invoke_body(&env, &caller_b),
|
|
serde_json::json!("default"),
|
|
"an app providing no module must fall back to the ext point's default"
|
|
);
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn extension_point_round_trips_through_pull() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::admin_env(fx);
|
|
let app = common::unique_slug("ep-pull");
|
|
|
|
let _a = AppGuard::new(&env.url, &env.token, &app);
|
|
common::pic_as(&env)
|
|
.args(["apps", "create", &app])
|
|
.assert()
|
|
.success();
|
|
|
|
// Apply an app manifest that declares an extension point with a default.
|
|
let dir = manifest_dir();
|
|
fs::write(
|
|
dir.path().join("scripts/theme.rhai"),
|
|
r#"fn name() { "x" }"#,
|
|
)
|
|
.unwrap();
|
|
let manifest = format!(
|
|
"[app]\nslug = \"{app}\"\nname = \"EP Pull\"\n\n\
|
|
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme.rhai\"\nkind = \"module\"\n\n\
|
|
[[extension_points]]\nname = \"theme_slot\"\ndefault = \"theme\"\n"
|
|
);
|
|
let manifest_path = dir.path().join("picloud.toml");
|
|
fs::write(&manifest_path, &manifest).unwrap();
|
|
common::pic_as(&env)
|
|
.args(["apply", "--file"])
|
|
.arg(&manifest_path)
|
|
.assert()
|
|
.success();
|
|
|
|
// Pull into a fresh dir, then re-plan: the extension point must round-trip
|
|
// (no Create/Delete), so `--prune` would never drop it.
|
|
let pulled = manifest_dir();
|
|
common::pic_as(&env)
|
|
.args(["pull", &app, "--dir"])
|
|
.arg(pulled.path())
|
|
.assert()
|
|
.success();
|
|
let toml = fs::read_to_string(pulled.path().join("picloud.toml")).unwrap();
|
|
assert!(
|
|
toml.contains("theme_slot"),
|
|
"pulled manifest must export the extension point:\n{toml}"
|
|
);
|
|
let plan = common::pic_as(&env)
|
|
.args(["plan", "--file"])
|
|
.arg(pulled.path().join("picloud.toml"))
|
|
.output()
|
|
.expect("plan");
|
|
let stdout = String::from_utf8_lossy(&plan.stdout);
|
|
assert!(
|
|
!stdout.to_lowercase().contains("create") && !stdout.to_lowercase().contains("delete"),
|
|
"re-planning a pulled manifest must be a no-op:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
/// Id of the named script in a group (`pic scripts ls --group <g>`).
|
|
fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String {
|
|
named_id(env, &["scripts", "ls", "--group", group], name)
|
|
}
|
|
|
|
/// Id of the named script in an app (`pic scripts ls --app <a>`).
|
|
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
|
|
named_id(env, &["scripts", "ls", "--app", app], name)
|
|
}
|
|
|
|
fn named_id(env: &common::TestEnv, args: &[&str], name: &str) -> String {
|
|
let ls = common::pic_as(env).args(args).output().expect("scripts ls");
|
|
let table = String::from_utf8(ls.stdout).unwrap();
|
|
table
|
|
.lines()
|
|
.map(common::cells)
|
|
.find(|c| c.get(2) == Some(&name))
|
|
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
|
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
|
|
}
|
|
|
|
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
|
|
let out = common::pic_as(env)
|
|
.args(["scripts", "invoke", id])
|
|
.output()
|
|
.expect("scripts invoke");
|
|
assert!(
|
|
out.status.success(),
|
|
"invoke failed: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
|
|
}
|