feat(hierarchies): template {var:NAME} resolves in-transaction (M6)

Route/trigger template `{var:NAME}` placeholders now resolve against vars
applied in the SAME `pic apply` transaction, not committed-only state — so a
var and a template referencing it converge in one apply instead of two.

- config_resolver: add tx-scoped `fetch_var_candidates_tx`, sharing the SQL
  tail (`VAR_CANDIDATES_TAIL`) with the pool variant so they can't drift.
- apply_service: `expand_route_templates_tx`/`expand_trigger_templates_tx`
  read vars via the tx (vars are reconciled in Phase A / the app's own
  reconcile, both before Phase B expansion).
- templates journey: `route_template_var_resolves_in_same_apply` — would fail
  against the old committed-only read.
- doc §4.5: strike the `{var:NAME}` committed-only limitation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-28 18:11:44 +02:00
parent 4d2eed4e81
commit 7c17d6e363
4 changed files with 116 additions and 18 deletions

View File

@@ -159,6 +159,78 @@ fn route_template_fans_out_per_app_idempotently_then_prunes() {
);
}
/// M6 (§4.5): a `{var:NAME}` placeholder resolves against a var set in the SAME
/// apply (in-transaction), so a var and a template referencing it converge in
/// one `pic apply`. Before M6 the var (written earlier in the tx) was invisible
/// to expansion — the route would only pick it up on a *second* apply.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn route_template_var_resolves_in_same_apply() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("tplv-g");
let a = common::unique_slug("tplv-a");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
common::pic_as(&env)
.args(["apps", "create", &a, "--group", &group])
.assert()
.success();
// The group sets `region` AND declares a template referencing it — one
// manifest, one apply.
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/shared.rhai"), r#""hi""#).unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
[vars]\nregion = \"eu\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
[[route_templates]]\nname = \"geo\"\nscript = \"shared\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/tv/{{app_slug}}/{{var:region}}\"\n"
),
)
.unwrap();
fs::create_dir_all(dir.path().join(&a)).unwrap();
fs::write(
dir.path().join(&a).join("picloud.toml"),
format!("[app]\nslug = \"{a}\"\nname = \"App\"\n"),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
// The expanded route resolved `{var:region}` → `eu` in THIS apply.
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
let want = format!("/tv/{a}/eu");
let rows = pg
.query(
"SELECT path FROM routes WHERE from_template IS NOT NULL AND path = $1",
&[&want],
)
.expect("query");
assert_eq!(
rows.len(),
1,
"`{{var:region}}` must resolve to `eu` in the same apply (expected path {want})"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn expansion_colliding_with_hand_declared_route_is_rejected() {