291 lines
14 KiB
Rust
291 lines
14 KiB
Rust
//! What a content repo must satisfy before it serves: every action
|
|||
|
|
//! lands on a page, every transition is one the state machine allows,
|
||
|
|
//! every recorded bucket is read back somewhere, and the built-in
|
||
|
|
//! aggregates the runtime owns are always present.
|
||
|
|
|
||
|
|
#[allow(unused_imports)] // the browser build sees fewer items
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
/// A relay must be a ws:// or wss:// URL - shared between site.yaml's
|
||
|
|
/// hero and `Requirement.relay` validation.
|
||
|
|
#[cfg(feature = "ssr")]
|
||
|
|
pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> {
|
||
|
|
let parsed = url::Url::parse(relay)
|
||
|
|
.map_err(|e| anyhow::anyhow!("relay {relay:?} is not a valid URL: {e}"))?;
|
||
|
|
if !matches!(parsed.scheme(), "ws" | "wss") {
|
||
|
|
anyhow::bail!("relay {relay:?} must use the ws:// or wss:// scheme");
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Validates every declared transition target (`SelfTransition.to`,
|
||
|
|
/// `ResourceSpec.transitions[].to`) against `aggregates` - the real
|
||
|
|
/// state graph loaded from `aggregates.yaml` - for every bucket that
|
||
|
|
/// has one declared. A bucket with no entry in `aggregates` is left
|
||
|
|
/// alone entirely (no validation applied): not every bucket needs to
|
||
|
|
/// be event-sourced to keep working. Called on every content
|
||
|
|
/// load/reload (`watch_for_reload`, `main.rs`'s boot path) and by the
|
||
|
|
/// standalone `question-lint` binary, so a YAML typo becomes a caught,
|
||
|
|
/// logged rejection instead of a silently-accepted, later-broken
|
||
|
|
/// string.
|
||
|
|
/// Bucket the runtime keeps for announced pages (`Question.event`):
|
||
|
|
/// one record per event question, `announced` while the window is
|
||
|
|
/// open, `awaiting_summary` once it closes - the "post what happened"
|
||
|
|
/// task a desk reading this bucket picks up - and `summarized` when an
|
||
|
|
/// owner marks it done. Declared here, not in content, because the
|
||
|
|
/// runtime's sweeper (`announce.rs`) is what moves the records.
|
||
|
|
pub const EVENTS_BUCKET: &str = "portal_events";
|
||
|
|
|
||
|
|
#[cfg(feature = "ssr")]
|
||
|
|
const BUILTIN_AGGREGATES_YAML: &str = "\
|
||
|
|
aggregates:
|
||
|
|
- bucket: portal_events
|
||
|
|
initial: announced
|
||
|
|
states:
|
||
|
|
announced: { event: announced }
|
||
|
|
awaiting_summary: { event: ended }
|
||
|
|
summarized: { event: summarized }
|
||
|
|
transitions:
|
||
|
|
announced: [awaiting_summary, summarized]
|
||
|
|
awaiting_summary: [summarized]
|
||
|
|
";
|
||
|
|
|
||
|
|
/// Adds the runtime's own state graphs to a content repo's. Content
|
||
|
|
/// wins if it declares the same bucket (say, to add states).
|
||
|
|
#[cfg(feature = "ssr")]
|
||
|
|
pub fn with_builtin_aggregates(
|
||
|
|
mut aggregates: std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
|
||
|
|
) -> std::collections::HashMap<String, crate::aggregates::AggregateSchema> {
|
||
|
|
let builtin = crate::aggregates::parse_aggregates_yaml(BUILTIN_AGGREGATES_YAML)
|
||
|
|
.expect("builtin aggregates yaml is valid");
|
||
|
|
for (bucket, schema) in builtin {
|
||
|
|
aggregates.entry(bucket).or_insert(schema);
|
||
|
|
}
|
||
|
|
aggregates
|
||
|
|
}
|
||
|
|
|
||
|
|
/// True when some kv resource in the content reads `bucket` - the
|
||
|
|
/// attended-bucket check, exposed so the lint can warn about the
|
||
|
|
/// runtime's own `portal_events` (a warning, not a failure: a repo
|
||
|
|
/// with no event pages has nothing to attend).
|
||
|
|
#[cfg(feature = "ssr")]
|
||
|
|
pub fn bucket_is_read(questions: &std::collections::HashMap<String, Question>, bucket: &str) -> bool {
|
||
|
|
questions.values().any(|q| {
|
||
|
|
q.alternatives.iter().any(|a| {
|
||
|
|
a.features
|
||
|
|
.iter()
|
||
|
|
.any(|f| f.resource.as_ref().and_then(|r| r.bucket()).is_some_and(|b| b == bucket))
|
||
|
|
})
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(feature = "ssr")]
|
||
|
|
pub fn validate_questions(
|
||
|
|
questions: &std::collections::HashMap<String, Question>,
|
||
|
|
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
|
||
|
|
) -> anyhow::Result<()> {
|
||
|
|
for question in questions.values() {
|
||
|
|
if let Some(event) = &question.event {
|
||
|
|
if question.is_dynamic() {
|
||
|
|
anyhow::bail!("question {:?}: a dynamic page can't carry an event", question.id);
|
||
|
|
}
|
||
|
|
if let Err(e) = event.window_ms() {
|
||
|
|
anyhow::bail!("question {:?}: event: {e}", question.id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if let Some(target) = &question.requires_chain {
|
||
|
|
if !questions.contains_key(target) {
|
||
|
|
anyhow::bail!(
|
||
|
|
"question {:?}: requires_chain {:?} does not match any declared question id",
|
||
|
|
question.id,
|
||
|
|
target
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for alternative in &question.alternatives {
|
||
|
|
// A dangling action is a literal dead end: the submit
|
||
|
|
// button navigates to "Nothing here".
|
||
|
|
if let Some(action) = &alternative.action {
|
||
|
|
if action.contains('{') {
|
||
|
|
// Placeholders are filled at submit time; the
|
||
|
|
// shape must land on a dynamic page once filled.
|
||
|
|
let probe = template_action(action, &serde_json::json!({}));
|
||
|
|
let filled = {
|
||
|
|
let mut a = action.clone();
|
||
|
|
while let (Some(i), Some(j)) = (a.find('{'), a.find('}')) {
|
||
|
|
a.replace_range(i..=j, "x");
|
||
|
|
}
|
||
|
|
a
|
||
|
|
};
|
||
|
|
let _ = probe;
|
||
|
|
// Match the pattern itself: resolve_question would
|
||
|
|
// hand back a clone whose id is already concrete.
|
||
|
|
let lands = questions
|
||
|
|
.values()
|
||
|
|
.any(|q| q.is_dynamic() && path_matches(&q.id, &filled).is_some());
|
||
|
|
if lands {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
anyhow::bail!(
|
||
|
|
"question {:?} alternative {:?}: templated action {:?} must land on a dynamic page ([name].yaml)",
|
||
|
|
question.id, alternative.name, action
|
||
|
|
);
|
||
|
|
}
|
||
|
|
match questions.get(action) {
|
||
|
|
None => anyhow::bail!(
|
||
|
|
"question {:?} alternative {:?}: action {:?} does not match any declared question id",
|
||
|
|
question.id, alternative.name, action
|
||
|
|
),
|
||
|
|
// A dynamic page needs a concrete segment value to
|
||
|
|
// be a URL - a submit button can't supply one.
|
||
|
|
Some(target) if target.is_dynamic() => anyhow::bail!(
|
||
|
|
"question {:?} alternative {:?}: action {:?} targets a dynamic page - actions must name a concrete question",
|
||
|
|
question.id, alternative.name, action
|
||
|
|
),
|
||
|
|
Some(_) => {}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if let Some(st) = &alternative.self_transition {
|
||
|
|
if let Some(schema) = aggregates.get(&st.bucket) {
|
||
|
|
if !schema.has_state(&st.to) {
|
||
|
|
anyhow::bail!(
|
||
|
|
"question {:?} alternative {:?}: self_transition.to {:?} is not a declared state for bucket {:?}",
|
||
|
|
question.id, alternative.name, st.to, st.bucket
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Bind targets resolve across the whole alternative, same
|
||
|
|
// scope as the signal maps the renderer builds.
|
||
|
|
let sibling_names: std::collections::HashSet<&str> = alternative
|
||
|
|
.features
|
||
|
|
.iter()
|
||
|
|
.flat_map(|f| f.requirements.iter())
|
||
|
|
.map(|r| r.name.as_str())
|
||
|
|
.collect();
|
||
|
|
for feature in &alternative.features {
|
||
|
|
for requirement in &feature.requirements {
|
||
|
|
if requirement.kind == "select"
|
||
|
|
&& requirement.resource.is_none()
|
||
|
|
&& requirement.options.is_empty()
|
||
|
|
{
|
||
|
|
anyhow::bail!(
|
||
|
|
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares neither a resource nor inline options",
|
||
|
|
question.id, alternative.name, feature.name, requirement.name
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if let Some(relay) = &requirement.relay {
|
||
|
|
if requirement.kind != "gesture" && requirement.kind != "voice" {
|
||
|
|
anyhow::bail!(
|
||
|
|
"question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture or voice",
|
||
|
|
question.id, alternative.name, feature.name, requirement.name, requirement.kind
|
||
|
|
);
|
||
|
|
}
|
||
|
|
validate_relay_url(relay).map_err(|e| anyhow::anyhow!(
|
||
|
|
"question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}",
|
||
|
|
question.id, alternative.name, feature.name, requirement.name
|
||
|
|
))?;
|
||
|
|
}
|
||
|
|
if requirement.kind == "gesture"
|
||
|
|
&& (requirement.resource.is_some() || requirement.bind.is_some())
|
||
|
|
{
|
||
|
|
anyhow::bail!(
|
||
|
|
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: gesture - it draws its value, it can't also load one from a resource or bind",
|
||
|
|
question.id, alternative.name, feature.name, requirement.name
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if let Some(bind) = &requirement.bind {
|
||
|
|
if bind.field == requirement.name
|
||
|
|
|| !sibling_names.contains(bind.field.as_str())
|
||
|
|
{
|
||
|
|
anyhow::bail!(
|
||
|
|
"question {:?} alternative {:?} feature {:?}: requirement {:?} binds to {:?}, which is not another requirement on this alternative",
|
||
|
|
question.id, alternative.name, feature.name, requirement.name, bind.field
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let Some(resource) = &feature.resource else {
|
||
|
|
continue;
|
||
|
|
};
|
||
|
|
let Some(bucket) = resource.bucket() else {
|
||
|
|
continue;
|
||
|
|
};
|
||
|
|
let Some(schema) = aggregates.get(bucket) else {
|
||
|
|
continue;
|
||
|
|
};
|
||
|
|
for transition in &resource.transitions {
|
||
|
|
if !schema.has_state(&transition.from) {
|
||
|
|
anyhow::bail!(
|
||
|
|
"question {:?} alternative {:?} feature {:?}: transition.from {:?} is not a declared state for bucket {:?}",
|
||
|
|
question.id, alternative.name, feature.name, transition.from, bucket
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if !schema.has_state(&transition.to) {
|
||
|
|
anyhow::bail!(
|
||
|
|
"question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}",
|
||
|
|
question.id, alternative.name, feature.name, transition.to, bucket
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) {
|
||
|
|
anyhow::bail!(
|
||
|
|
"question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}",
|
||
|
|
question.id, alternative.name, feature.name, transition.from, transition.to, bucket
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Attended buckets: no publicly collected answer may land somewhere
|
||
|
|
// nothing reads. Every `record_as` bucket must either be read back
|
||
|
|
// by some Kv resource in this same content repo (a desk or listing)
|
||
|
|
// or carry an explicit `attended_by:` annotation in aggregates.yaml
|
||
|
|
// naming the automation that consumes it. This is a contract, not a
|
||
|
|
// convention, because convention already failed once: a question
|
||
|
|
// was dropped and its bucket - answers included - silently fell out
|
||
|
|
// of every reader's view.
|
||
|
|
let mut read_buckets = std::collections::HashSet::new();
|
||
|
|
let note_resource = |spec: &ResourceSpec, set: &mut std::collections::HashSet<String>| {
|
||
|
|
if let ResourceSource::Kv { bucket } = &spec.source {
|
||
|
|
set.insert(bucket.clone());
|
||
|
|
}
|
||
|
|
};
|
||
|
|
for question in questions.values() {
|
||
|
|
for alternative in &question.alternatives {
|
||
|
|
for feature in &alternative.features {
|
||
|
|
if let Some(spec) = &feature.resource {
|
||
|
|
note_resource(spec, &mut read_buckets);
|
||
|
|
}
|
||
|
|
for requirement in &feature.requirements {
|
||
|
|
if let Some(spec) = &requirement.resource {
|
||
|
|
note_resource(spec, &mut read_buckets);
|
||
|
|
}
|
||
|
|
if let Some(bind) = &requirement.bind {
|
||
|
|
note_resource(&bind.resource, &mut read_buckets);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for question in questions.values() {
|
||
|
|
for alternative in &question.alternatives {
|
||
|
|
if let Some(bucket) = &alternative.record_as {
|
||
|
|
let attended = read_buckets.contains(bucket)
|
||
|
|
|| aggregates
|
||
|
|
.get(bucket)
|
||
|
|
.is_some_and(|schema| schema.attended_by.is_some());
|
||
|
|
if !attended {
|
||
|
|
anyhow::bail!(
|
||
|
|
"question {:?} alternative {:?}: record_as {bucket:?} is unattended - no page reads that bucket back, and aggregates.yaml declares no attended_by for it. Add a desk/listing resource over it, or annotate the automation that consumes it.",
|
||
|
|
question.id, alternative.name
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|