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

@@ -2184,10 +2184,11 @@ impl ApplyService {
}
}
// Effective vars for `{var:NAME}` — committed state (a var set in this
// same apply is visible to templates on the NEXT apply, not this one).
// Effective vars for `{var:NAME}` — read in-transaction so a var set in
// THIS same apply (reconciled earlier in the tx) is visible to the
// template now, not one apply late.
let vars = {
let cands = crate::config_resolver::fetch_var_candidates(&self.pool, app_id)
let cands = crate::config_resolver::fetch_var_candidates_tx(tx, app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
crate::config_resolver::resolve(cands).0
@@ -2428,9 +2429,10 @@ impl ApplyService {
}
}
// Effective vars for `{var:NAME}` (committed state — see route expansion).
// Effective vars for `{var:NAME}` — read in-transaction (see route
// expansion): a var set in this same apply is visible now.
let vars = {
let cands = crate::config_resolver::fetch_var_candidates(&self.pool, app_id)
let cands = crate::config_resolver::fetch_var_candidates_tx(tx, app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
crate::config_resolver::resolve(cands).0

View File

@@ -226,17 +226,7 @@ pub async fn fetch_var_candidates(
pool: &PgPool,
app_id: AppId,
) -> Result<Vec<Candidate>, sqlx::Error> {
let sql = format!(
"{CHAIN_LEVELS_CTE} \
SELECT c.depth, \
CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
COALESCE(v.app_id, v.group_id) AS owner_id, \
v.environment_scope, v.key, v.value, v.is_tombstone \
FROM chain c \
JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \
WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \
ORDER BY c.depth ASC"
);
let sql = format!("{CHAIN_LEVELS_CTE} {VAR_CANDIDATES_TAIL}");
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
.bind(app_id.into_inner())
.fetch_all(pool)
@@ -244,6 +234,39 @@ pub async fn fetch_var_candidates(
Ok(rows.into_iter().map(Into::into).collect())
}
/// Transaction-scoped twin of [`fetch_var_candidates`]: runs the identical
/// `CHAIN_LEVELS_CTE` query against an open transaction, so it sees vars
/// **written earlier in the same transaction** (not just committed state).
/// Used by template expansion so a `{var:NAME}` placeholder resolves against
/// a var set in the *same* `pic apply` rather than landing one apply late.
///
/// # Errors
/// Propagates sqlx errors.
pub async fn fetch_var_candidates_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId,
) -> Result<Vec<Candidate>, sqlx::Error> {
let sql = format!("{CHAIN_LEVELS_CTE} {VAR_CANDIDATES_TAIL}");
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
.bind(app_id.into_inner())
.fetch_all(&mut **tx)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// The `SELECT … FROM chain …` tail appended after [`CHAIN_LEVELS_CTE`] to
/// pull env-eligible `vars` candidates, nearest-first. Shared by the pool and
/// transaction variants so the two can't drift.
const VAR_CANDIDATES_TAIL: &str = "\
SELECT c.depth, \
CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
COALESCE(v.app_id, v.group_id) AS owner_id, \
v.environment_scope, v.key, v.value, v.is_tombstone \
FROM chain c \
JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \
WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \
ORDER BY c.depth ASC";
/// The masked, resolved view of one inherited secret for `config/effective`:
/// which owner/level/scope supplies it — **never** the value.
#[derive(Debug, Clone)]