Skip to main content

eui_core/host/
package.rs

1//! Catalog negotiation and declarative app packages (VAPP-82: a manifest +
2//! A2UI templates + bindings, data never code).
3
4use std::collections::HashSet;
5
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Map, Value};
8
9use super::policy::matches_pattern;
10use super::query::parse_records_json;
11use super::record_store::RECORDS_SCHEME;
12use super::records::{is_record_kind, validate_record_schema};
13use super::sources::parse_source;
14use super::exposes::{validate_package_exposes, validate_upstreams};
15use super::tools::validate_tools;
16use crate::catalog::{A2UI_BASIC_CATALOG_ID, A2UI_VERSION, CORE_CATALOG_ID, CORE_LITE_CATALOG_ID};
17use crate::types::ExtensionDef;
18
19/// VAPP-161: the package format version this host reads
20/// (catalog/host.json `package.formatVersion`; docs/package-format.md). A
21/// package names it, and it is exactly this one: no older format is read
22/// (eApps keeps no backwards compatibility).
23pub const PACKAGE_FORMAT_VERSION: u32 = 3;
24/// `package.extensions.versionPattern`.
25pub const EXTENSION_VERSION_PATTERN: &str = "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$";
26
27/// VAPP-161: an extension as a host registered it ([`available_extensions`]).
28#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
29pub struct AvailableExtension {
30    pub id: String,
31    /// `None` = satisfies any version.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub version: Option<String>,
34    /// `None` = not checked.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub components: Option<Vec<String>>,
37}
38
39/// A host's extension catalogs as [`validate_package_with`] checks them:
40/// the version when set (an empty one is unset), the components then the
41/// macros.
42pub fn available_extensions(defs: &[ExtensionDef]) -> Vec<AvailableExtension> {
43    defs.iter()
44        .map(|d| AvailableExtension {
45            id: d.id.clone(),
46            version: d.version.clone().filter(|v| !v.is_empty()),
47            components: Some(d.components.keys().chain(d.macros.iter().flat_map(|m| m.keys())).cloned().collect()),
48        })
49        .collect()
50}
51
52/// `^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$` → the three parts (as JS
53/// `Number`s).
54fn semver(v: &str) -> Option<[f64; 3]> {
55    let mut parts = v.split('.');
56    let mut out = [0.0; 3];
57    for slot in &mut out {
58        let p = parts.next()?;
59        if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) || (p.len() > 1 && p.starts_with('0')) {
60            return None;
61        }
62        *slot = p.parse().ok()?;
63    }
64    parts.next().is_none().then_some(out)
65}
66
67/// Caret: `have` has `want`'s major and is not lower.
68pub fn version_satisfies(have: &str, want: &str) -> bool {
69    let (Some([h_major, h_minor, h_patch]), Some([w_major, w_minor, w_patch])) = (semver(have), semver(want)) else { return false };
70    h_major == w_major && (h_minor > w_minor || (h_minor == w_minor && h_patch >= w_patch))
71}
72
73/// The core, the core lite, the A2UI basic catalog, then every registered
74/// extension id in order, de-duplicated.
75pub fn supported_catalog_ids<S: AsRef<str>>(extension_ids: &[S]) -> Vec<String> {
76    let mut out: Vec<String> = Vec::new();
77    for id in [CORE_CATALOG_ID, CORE_LITE_CATALOG_ID, A2UI_BASIC_CATALOG_ID].into_iter().chain(extension_ids.iter().map(AsRef::as_ref)) {
78        if !out.iter().any(|x| x == id) {
79            out.push(id.to_string());
80        }
81    }
82    out
83}
84
85/// A2UI's `a2uiClientCapabilities`.
86pub fn client_capabilities<S: AsRef<str>>(extension_ids: &[S]) -> Value {
87    json!({"v0.9": {"supportedCatalogIds": supported_catalog_ids(extension_ids)}})
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct PackageIssue {
92    pub path: String,
93    pub message: String,
94}
95
96fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
97    PackageIssue { path: path.into(), message: message.into() }
98}
99
100/// `^(\/([^/~]|~[01])*)*$`: empty, or `/`-separated segments whose `~` is
101/// always `~0` / `~1`.
102fn is_pointer(s: &str) -> bool {
103    if s.is_empty() {
104        return true;
105    }
106    if !s.starts_with('/') {
107        return false;
108    }
109    let mut chars = s.chars().peekable();
110    while let Some(c) = chars.next() {
111        if c == '~' && !matches!(chars.next(), Some('0' | '1')) {
112            return false;
113        }
114    }
115    true
116}
117
118/// Every problem with a package, each with a JSON pointer into it.
119/// `catalog_ids` = the supported ids (default: [`supported_catalog_ids`] of
120/// none). A declared extension only has to be in `catalog_ids`
121/// ([`validate_package_with`] checks versions and components too).
122pub fn validate_package<S: AsRef<str>>(pkg: &Value, catalog_ids: &[S]) -> Vec<PackageIssue> {
123    validate_package_with(pkg, catalog_ids, None)
124}
125
126/// [`validate_package`] with the host's registered `extensions` (versions
127/// and components); `None` = a declared extension only has to be in
128/// `catalog_ids`.
129pub fn validate_package_with<S: AsRef<str>>(pkg: &Value, catalog_ids: &[S], extensions: Option<&[AvailableExtension]>) -> Vec<PackageIssue> {
130    validate_package_in(pkg, catalog_ids, extensions, &crate::catalog::CatalogView::core())
131}
132
133/// [`validate_package_with`] reducing the templates against `view` (VAPP-203:
134/// the core plus the extensions the host has definitions for; the TS
135/// `AvailableExtension.def`). A catalog the view does not know leaves its
136/// component names open.
137pub fn validate_package_in<S: AsRef<str>>(pkg: &Value, catalog_ids: &[S], extensions: Option<&[AvailableExtension]>, view: &std::sync::Arc<crate::catalog::CatalogView>) -> Vec<PackageIssue> {
138    let mut issues = Vec::new();
139    let Some(p) = pkg.as_object() else { return vec![issue("", "a package is an object")] };
140    match p.get("formatVersion").map(|version| version.as_f64().filter(|v| v.fract() == 0.0 && *v >= 1.0)) {
141        None => issues.push(issue("/formatVersion", format!("formatVersion is required ({PACKAGE_FORMAT_VERSION})"))),
142        Some(None) => issues.push(issue("/formatVersion", "formatVersion is a positive integer")),
143        Some(Some(v)) if v > f64::from(PACKAGE_FORMAT_VERSION) => {
144            issues.push(issue("/formatVersion", format!("format version {} is newer than this host ({PACKAGE_FORMAT_VERSION})", crate::json::number_to_string(v))))
145        }
146        Some(Some(v)) if v < f64::from(PACKAGE_FORMAT_VERSION) => {
147            issues.push(issue("/formatVersion", format!("format version {} is no longer read: write {PACKAGE_FORMAT_VERSION}", crate::json::number_to_string(v))))
148        }
149        Some(Some(_)) => {}
150    }
151    for key in ["id", "name", "version", "catalogId"] {
152        if p.get(key).and_then(Value::as_str).is_none_or(str::is_empty) {
153            issues.push(issue(format!("/{key}"), format!("{key} is a required string")));
154        }
155    }
156    if let Some(catalog_id) = p.get("catalogId").and_then(Value::as_str).filter(|s| !s.is_empty()) {
157        if !catalog_ids.iter().any(|c| c.as_ref() == catalog_id) {
158            issues.push(issue("/catalogId", format!("unsupported catalog {catalog_id}")));
159        }
160    }
161    match p.get("templates").and_then(Value::as_object).filter(|t| !t.is_empty()) {
162        None => issues.push(issue("/templates", "templates is a non-empty object")),
163        Some(templates) => {
164            // Sorted by id: a host whose JSON layer reorders keys (Swift's
165            // JSONSerialization) reports the issues in the fixture's order.
166            for (id, template) in sorted_entries(templates) {
167                let at = format!("/templates/{id}");
168                let Some(template) = template.as_object() else {
169                    issues.push(issue(at, "a template is an object"));
170                    continue;
171                };
172                match template.get("components").and_then(Value::as_array).filter(|c| !c.is_empty()) {
173                    None => issues.push(issue(format!("{at}/components"), "components is a non-empty array")),
174                    Some(components) => {
175                        let mut ids: HashSet<&str> = HashSet::new();
176                        for (i, c) in components.iter().enumerate() {
177                            let id = c.as_object().filter(|o| o.get("component").is_some_and(Value::is_string)).and_then(|o| o.get("id")).and_then(Value::as_str);
178                            match id {
179                                None => issues.push(issue(format!("{at}/components/{i}"), "a component needs an id and a component")),
180                                Some(id) if ids.contains(id) => issues.push(issue(format!("{at}/components/{i}/id"), format!("duplicate id {id}"))),
181                                Some(id) => {
182                                    ids.insert(id);
183                                }
184                            }
185                        }
186                        if !ids.contains("root") {
187                            issues.push(issue(format!("{at}/components"), "no component has the id root"));
188                        }
189                    }
190                }
191                if let Some(bindings) = template.get("bindings") {
192                    match bindings.as_array() {
193                        None => issues.push(issue(format!("{at}/bindings"), "bindings is an array")),
194                        Some(list) => {
195                            for (i, b) in list.iter().enumerate() {
196                                let bp = format!("{at}/bindings/{i}");
197                                let b = b.as_object();
198                                if b.and_then(|o| o.get("path")).and_then(Value::as_str).is_none_or(|p| !is_pointer(p)) {
199                                    issues.push(issue(format!("{bp}/path"), "path is a JSON pointer"));
200                                }
201                                if b.and_then(|o| o.get("source")).and_then(Value::as_str).is_none_or(|s| parse_source(s).is_none()) {
202                                    issues.push(issue(format!("{bp}/source"), "source is a scheme:name URI"));
203                                }
204                            }
205                        }
206                    }
207                }
208            }
209        }
210    }
211    if let Some(functions) = p.get("functions") {
212        let ok = functions.as_array().is_some_and(|list| list.iter().all(|f| f.as_str().is_some_and(|s| !s.is_empty())));
213        if !ok {
214            issues.push(issue("/functions", "functions is a list of names or prefix* patterns"));
215        }
216    }
217    // VAPP-122: `records` = the app's kinds, each with a schema.
218    if let Some(records) = p.get("records") {
219        match records.as_object() {
220            None => issues.push(issue("/records", "records is an object of kinds")),
221            Some(kinds) => {
222                let mut names: Vec<&String> = kinds.keys().collect();
223                names.sort();
224                for kind in names {
225                    let def = &kinds[kind];
226                    let at = format!("/records/{kind}");
227                    if !is_record_kind(kind) {
228                        issues.push(issue(&at, "a kind is lowercase letters, digits and underscores"));
229                    }
230                    match def.as_object() {
231                        None => issues.push(issue(&at, "a kind is {schema, title?, policy?}")),
232                        Some(def) => {
233                            issues.extend(validate_record_schema(def.get("schema").unwrap_or(&Value::Null), &format!("{at}/schema")));
234                            // eApps R3: who may read, create, update and delete.
235                            if let Some(policy) = def.get("policy") {
236                                issues.extend(super::record_policy::validate_record_policy(policy, &format!("{at}/policy")));
237                            }
238                            // VAPP-214 (Q3): computed fields, typed against the schema.
239                            issues.extend(super::expressions::validate_record_computed(&Value::Object(def.clone()), &at));
240                            // eApps R5 (VAPP-219): refs name a string field and a declared kind.
241                            issues.extend(super::records::validate_record_refs(&Value::Object(def.clone()), &at, p.get("records")));
242                            // eApps R6 (VAPP-222): a state machine on one enum field.
243                            issues.extend(super::record_transitions::validate_record_transitions(&Value::Object(def.clone()), &at, kind));
244                        }
245                    }
246                }
247            }
248        }
249    }
250    // VAPP-107: `sources` = the binding sources the package's surfaces may
251    // follow; a template binding outside it is an issue.
252    if let Some(sources) = p.get("sources") {
253        match sources.as_array().filter(|list| list.iter().all(|f| f.as_str().is_some_and(|s| !s.is_empty()))) {
254            None => issues.push(issue("/sources", "sources is a list of scheme:name sources or prefix* patterns")),
255            Some(list) => {
256                let allowed: Vec<String> = list.iter().filter_map(Value::as_str).map(str::to_string).collect();
257                for (id, template) in p.get("templates").and_then(Value::as_object).map(sorted_entries).into_iter().flatten() {
258                    for (i, b) in template.get("bindings").and_then(Value::as_array).into_iter().flatten().enumerate() {
259                        if let Some(source) = b.get("source").and_then(Value::as_str).filter(|s| parse_source(s).is_some()) {
260                            if !source_allowed(Some(&allowed), source) {
261                                issues.push(issue(format!("/templates/{id}/bindings/{i}/source"), format!("source {source} is not in the package's sources")));
262                            }
263                        }
264                    }
265                }
266            }
267        }
268    }
269    // eApps R8 (VAPP-233): declarative record migrations.
270    issues.extend(super::record_migrations::validate_migrations(pkg));
271    // VAPP-161: a `records:<kind>` binding names a declared kind.
272    for (id, template) in p.get("templates").and_then(Value::as_object).map(sorted_entries).into_iter().flatten() {
273        let Some(bindings) = template.as_object().and_then(|t| t.get("bindings")).and_then(Value::as_array) else { continue };
274        for (i, b) in bindings.iter().enumerate() {
275            let Some(parsed) = b.as_object().and_then(|o| o.get("source")).and_then(Value::as_str).and_then(parse_source) else { continue };
276            if parsed.scheme != RECORDS_SCHEME {
277                continue;
278            }
279            if !p.get("records").and_then(Value::as_object).is_some_and(|r| r.contains_key(&parsed.name)) {
280                issues.push(issue(format!("/templates/{id}/bindings/{i}/source"), format!("records:{} names no declared kind", parsed.name)));
281            }
282        }
283    }
284    // VAPP-190: the named queries parse clean, read a declared kind, and a
285    // records binding's `query=` names one of them.
286    if let Some(queries) = p.get("queries") {
287        match queries.as_object() {
288            None => issues.push(issue("/queries", "queries is an object of named queries")),
289            Some(queries) => {
290                let mut names: Vec<&String> = queries.keys().collect();
291                names.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
292                for name in names {
293                    let at = format!("/queries/{name}");
294                    if !is_query_name(name) {
295                        issues.push(issue(at.clone(), "a query name is a letter, then letters, digits and underscores"));
296                    }
297                    let q = &queries[name];
298                    for i in parse_records_json(q, None).1 {
299                        issues.push(issue(format!("{at}{}", i.path), i.message));
300                    }
301                    let from = q.as_object().and_then(|o| o.get("from")).and_then(Value::as_str).map(super::js_trim).unwrap_or("");
302                    if !from.is_empty() && !from.contains("${") && !p.get("records").and_then(Value::as_object).is_some_and(|r| r.contains_key(from)) {
303                        issues.push(issue(format!("{at}/from"), format!("records:{from} names no declared kind")));
304                    } else if !from.is_empty() && !from.contains("${") && q.is_object() {
305                        issues.extend(query_field_issues(q, from, p.get("records").and_then(|r| r.get(from)), &at));
306                    }
307                    // eApps R5: an expanded field is a ref of the kind.
308                    let def = (!from.is_empty()).then(|| p.get("records").and_then(|r| r.get(from))).flatten().filter(|d| d.is_object());
309                    if let (Some(def), Some(expand)) = (def, q.get("expand").and_then(Value::as_array)) {
310                        let refs = def.get("refs");
311                        for (i, f) in expand.iter().enumerate() {
312                            let Some(f) = f.as_str().map(super::js_trim).filter(|f| !f.is_empty()) else { continue };
313                            let known = match refs {
314                                None => false,
315                                Some(r) => r.as_object().is_some_and(|r| r.contains_key(f)),
316                            };
317                            if !known {
318                                issues.push(issue(format!("{at}/expand/{i}"), format!("{f} is not a ref of {from}")));
319                            }
320                        }
321                    }
322                }
323            }
324        }
325    }
326    // Dogfood G: an exposed records run's `query` names a package query of the run's kind.
327    for (i, expose) in p.get("exposes").and_then(Value::as_array).into_iter().flatten().enumerate() {
328        let Some(run) = expose.get("run").and_then(|r| r.get("records")).filter(|r| r.is_object()) else { continue };
329        let Some(name) = run.get("query").and_then(Value::as_str) else { continue };
330        let at = format!("/exposes/{i}/run/records/query");
331        let Some(q) = p.get("queries").and_then(Value::as_object).and_then(|qs| qs.get(name)) else {
332            issues.push(issue(at, format!("query {name} names no package query")));
333            continue;
334        };
335        let from = q.as_object().and_then(|o| o.get("from")).and_then(Value::as_str).map(super::js_trim).unwrap_or("");
336        if let Some(kind) = run.get("kind").and_then(Value::as_str) {
337            if !from.is_empty() && !from.contains("${") && from != kind {
338                issues.push(issue(at, format!("query {name} reads records:{from}, the tool's kind is {kind}")));
339            }
340        }
341    }
342    for (id, template) in p.get("templates").and_then(Value::as_object).map(sorted_entries).into_iter().flatten() {
343        let Some(bindings) = template.as_object().and_then(|t| t.get("bindings")).and_then(Value::as_array) else { continue };
344        for (i, b) in bindings.iter().enumerate() {
345            let Some(parsed) = b.as_object().and_then(|o| o.get("source")).and_then(Value::as_str).and_then(parse_source) else { continue };
346            let Some(name) = parsed.params.get("query") else { continue };
347            if parsed.scheme != RECORDS_SCHEME {
348                continue;
349            }
350            if !p.get("queries").and_then(Value::as_object).is_some_and(|q| q.contains_key(name)) {
351                issues.push(issue(format!("/templates/{id}/bindings/{i}/source"), format!("query {name} names no package query")));
352            }
353        }
354    }
355    // VAPP-203: every template reduces clean, as the host will render it.
356    issues.extend(super::components::validate_package_components(p, catalog_ids, view));
357    // VAPP-191: every expression in a template parses and type-checks.
358    issues.extend(super::expressions::validate_package_expressions(p));
359    // VAPP-194: bindings are pure; actions are checked step by step.
360    issues.extend(super::actions::validate_package_effects(p));
361    issues.extend(super::actions::validate_package_actions(p));
362    // VAPP-218: the screens, and every literal navigate target.
363    issues.extend(super::screens::validate_package_screens(p));
364    if let Some(tools) = p.get("tools") {
365        issues.extend(validate_tools(tools));
366    }
367    if let Some(upstreams) = p.get("upstreams") {
368        issues.extend(validate_upstreams(upstreams));
369    }
370    if let Some(exposes) = p.get("exposes") {
371        issues.extend(validate_package_exposes(exposes, p.get("records"), p.get("upstreams")));
372    }
373    if let Some(list) = p.get("extensions") {
374        issues.extend(validate_extensions(list, catalog_ids, extensions));
375    }
376    // VAPP-196: the package's tests: their shape, then (on an otherwise valid
377    // package) every one run; a failing test is an issue like a type error.
378    issues.extend(super::tests::validate_package_tests(p));
379    if issues.is_empty() {
380        issues.extend(super::tests::package_test_issues(pkg));
381    }
382    issues
383}
384
385fn validate_extensions<S: AsRef<str>>(list: &Value, catalog_ids: &[S], available: Option<&[AvailableExtension]>) -> Vec<PackageIssue> {
386    let Some(list) = list.as_array() else { return vec![issue("/extensions", "extensions is a list of {id, version, components?}")] };
387    let mut issues = Vec::new();
388    let mut seen: HashSet<&str> = HashSet::new();
389    for (i, ext) in list.iter().enumerate() {
390        let at = format!("/extensions/{i}");
391        let Some(ext) = ext.as_object() else {
392            issues.push(issue(at, "an extension is {id, version, components?}"));
393            continue;
394        };
395        let id = ext.get("id").and_then(Value::as_str).filter(|s| !s.is_empty());
396        if id.is_none() {
397            issues.push(issue(format!("{at}/id"), "id is a required string"));
398        }
399        let version = ext.get("version").and_then(Value::as_str).filter(|v| semver(v).is_some());
400        if version.is_none() {
401            issues.push(issue(format!("{at}/version"), "version is MAJOR.MINOR.PATCH"));
402        }
403        let components: Option<Vec<&str>> = match ext.get("components") {
404            None => Some(Vec::new()),
405            Some(c) => c.as_array().and_then(|l| l.iter().map(|c| c.as_str().filter(|s| !s.is_empty())).collect()),
406        };
407        if components.is_none() {
408            issues.push(issue(format!("{at}/components"), "components is a list of component names"));
409        }
410        let Some(id) = id else { continue };
411        if !seen.insert(id) {
412            issues.push(issue(format!("{at}/id"), format!("duplicate extension {id}")));
413            continue;
414        }
415        let have = available.and_then(|a| a.iter().find(|a| a.id == id));
416        if !catalog_ids.iter().any(|c| c.as_ref() == id) || (available.is_some() && have.is_none()) {
417            issues.push(issue(format!("{at}/id"), format!("extension {id} is not available on this host")));
418            continue;
419        }
420        let Some(have) = have else { continue };
421        if let (Some(version), Some(have_version)) = (version, have.version.as_deref()) {
422            if !version_satisfies(have_version, version) {
423                issues.push(issue(format!("{at}/version"), format!("extension {id} {have_version} does not satisfy ^{version}")));
424            }
425        }
426        if let Some(have_components) = &have.components {
427            for (j, name) in components.iter().flatten().enumerate() {
428                if !have_components.iter().any(|c| c == name) {
429                    issues.push(issue(format!("{at}/components/{j}"), format!("extension {id} has no component {name}")));
430                }
431            }
432        }
433    }
434    issues
435}
436
437/// VAPP-107: may a package whose `sources` list is `allowed` follow this
438/// source URI? `None` = yes; else the URI's `scheme:name` (scheme
439/// lower-cased, the query dropped) must match a listed source exactly or a
440/// `prefix*` pattern.
441pub fn source_allowed(allowed: Option<&[String]>, source: &str) -> bool {
442    let Some(allowed) = allowed else { return true };
443    let Some(parsed) = parse_source(source) else { return false };
444    let name = format!("{}:{}", parsed.scheme, parsed.name);
445    allowed.iter().any(|p| matches_pattern(p, &name) || matches_pattern(p, source))
446}
447
448/// A package's `sources` allowlist (`None` = unset: any source).
449pub fn package_sources(pkg: &Value) -> Option<Vec<String>> {
450    pkg.get("sources").and_then(Value::as_array).map(|l| l.iter().filter_map(Value::as_str).map(str::to_string).collect())
451}
452
453/// A template as the messages that create it: createSurface, the components,
454/// the merged initial data, one bindDataModel per binding. `None` when the
455/// package has no such template. `data` = the caller's data (`Some(Null)` is
456/// a value, unlike `None`).
457pub fn template_messages(pkg: &Value, template_id: &str, surface_id: &str, data: Option<&Value>) -> Option<Vec<Value>> {
458    let template = pkg.get("templates").and_then(|t| t.get(template_id)).filter(|t| super::js_truthy(t))?;
459    let version = A2UI_VERSION;
460    let mut create = Map::new();
461    create.insert("surfaceId".into(), Value::String(surface_id.into()));
462    if let Some(catalog_id) = pkg.get("catalogId") {
463        create.insert("catalogId".into(), catalog_id.clone());
464    }
465    let mut components = Map::new();
466    components.insert("surfaceId".into(), Value::String(surface_id.into()));
467    if let Some(list) = template.get("components") {
468        components.insert("components".into(), list.clone());
469    }
470    let mut out = vec![json!({"version": version, "createSurface": create}), json!({"version": version, "updateComponents": components})];
471    let merged = merge_data(template.get("data"), data);
472    // VAPP-218: a screen's `${params.<key>}` in a source, from the data's params.
473    let params = merged.as_ref().and_then(|m| m.get("params")).cloned();
474    if let Some(merged) = merged {
475        out.push(json!({"version": version, "updateDataModel": {"surfaceId": surface_id, "path": "/", "value": merged}}));
476    }
477    for b in template.get("bindings").and_then(Value::as_array).into_iter().flatten() {
478        let mut bind = Map::new();
479        bind.insert("surfaceId".into(), Value::String(surface_id.into()));
480        for key in ["path", "source"] {
481            if let Some(v) = b.get(key) {
482                let v = match (key, v.as_str()) {
483                    ("source", Some(src)) => Value::String(super::screens::fill_screen_params(src, params.as_ref())),
484                    _ => v.clone(),
485                };
486                bind.insert(key.into(), v);
487            }
488        }
489        out.push(json!({"version": version, "bindDataModel": bind}));
490    }
491    Some(out)
492}
493
494/// The template's data under the caller's (objects merge one level deep in
495/// key order; anything else: the caller's wins). `None` = undefined.
496pub fn merge_data(base: Option<&Value>, over: Option<&Value>) -> Option<Value> {
497    match (base, over) {
498        (base, None) => base.cloned(),
499        (Some(Value::Object(b)), Some(Value::Object(o))) => {
500            let mut merged = b.clone();
501            for (k, v) in o {
502                merged.insert(k.clone(), v.clone());
503            }
504            Some(Value::Object(merged))
505        }
506        (_, Some(over)) => Some(over.clone()),
507    }
508}
509
510/// VAPP-190: a package query's name (catalog/host.json `package.records.query.queryNamePattern`).
511pub const QUERY_NAME_PATTERN: &str = "^[a-z][A-Za-z0-9_]{0,31}$";
512
513/// [`QUERY_NAME_PATTERN`] without a regex engine.
514pub fn is_query_name(name: &str) -> bool {
515    let b = name.as_bytes();
516    !b.is_empty() && b.len() <= 32 && b[0].is_ascii_lowercase() && b[1..].iter().all(|c| c.is_ascii_alphanumeric() || *c == b'_')
517}
518
519/// A JSON object's entries sorted by key (the issue order every host
520/// shares, whatever order its JSON layer kept).
521pub(crate) fn sorted_entries(map: &serde_json::Map<String, Value>) -> Vec<(&String, &Value)> {
522    let mut entries: Vec<(&String, &Value)> = map.iter().collect();
523    entries.sort_by(|a, b| a.0.cmp(b.0));
524    entries
525}
526
527/// Dogfood G: a named query reads fields its kind has. Every field it filters,
528/// groups, aggregates, sums, sorts, buckets or runs on is a schema field, a
529/// computed field or a row key (a sort, a running value: or one of its
530/// aggregates, `count`), and `sum`/`avg` read a number. Else the query
531/// silently reads null. Issues at the query's top-level key (TS twin
532/// `queryFieldIssues`).
533fn query_field_issues(json: &Value, kind: &str, def: Option<&Value>, at: &str) -> Vec<PackageIssue> {
534    use super::query::RecordsAggregateOp as Op;
535    let (query, _) = parse_records_json(json, Some(kind));
536    let row = super::expressions::record_row_type(def);
537    let row = row.as_object().cloned().unwrap_or_default();
538    let has = |f: &str| row.contains_key(f);
539    let mut outputs: HashSet<String> = HashSet::from(["count".to_string()]);
540    outputs.extend(query.aggregate.iter().map(|a| a.name.clone()));
541    outputs.extend(query.group.iter().map(|g| g.field.clone()));
542    let mut issues = Vec::new();
543    let mut seen: HashSet<(String, String)> = HashSet::new();
544    let mut unknown = |issues: &mut Vec<PackageIssue>, key: String, f: &str, also: bool| {
545        if has(f) || also || !seen.insert((key.clone(), f.to_string())) {
546            return;
547        }
548        issues.push(issue(format!("{at}/{key}"), format!("{f} is no field of records:{kind}")));
549    };
550    let numeric = |issues: &mut Vec<PackageIssue>, key: String, op: &str, f: &str| {
551        let t = row.get(f).map(crate::cel::required_type).unwrap_or_else(|| json!("dyn"));
552        if t != json!("number") && t != json!("dyn") {
553            let name = match &t {
554                Value::String(s) => s.clone(),
555                Value::Array(_) => "list".into(),
556                _ => "map".into(),
557            };
558            issues.push(issue(format!("{at}/{key}"), format!("{op} reads a number: {f} is {name}")));
559        }
560    };
561    let listed = |k: &str| json.get(k).is_some_and(Value::is_array);
562    for f in &query.where_ {
563        unknown(&mut issues, "where".into(), &f.field, false);
564    }
565    for alt in &query.or {
566        for f in alt {
567            unknown(&mut issues, "or".into(), &f.field, false);
568        }
569    }
570    for (i, g) in query.group.iter().enumerate() {
571        unknown(&mut issues, if listed("group") { format!("group/{i}") } else { "group".into() }, &g.field, false);
572    }
573    for a in &query.aggregate {
574        let Some(f) = &a.field else { continue };
575        unknown(&mut issues, format!("aggregate/{}", a.name), f, false);
576        if matches!(a.op, Op::Sum | Op::Avg) && has(f) {
577            numeric(&mut issues, format!("aggregate/{}", a.name), if matches!(a.op, Op::Sum) { "sum" } else { "avg" }, f);
578        }
579    }
580    if let Some(f) = &query.sum {
581        unknown(&mut issues, "sum".into(), f, false);
582        if has(f) {
583            numeric(&mut issues, "sum".into(), "sum", f);
584        }
585    }
586    if let Some(m) = &query.month {
587        unknown(&mut issues, "month".into(), &m.field, false);
588    }
589    if let Some(b) = &query.before {
590        unknown(&mut issues, "before".into(), &b.field, false);
591    }
592    for (i, s) in query.sort.iter().enumerate() {
593        unknown(&mut issues, if listed("sort") { format!("sort/{i}") } else { "sort".into() }, &s.field, outputs.contains(&s.field));
594    }
595    for r in &query.running {
596        unknown(&mut issues, format!("running/{}", r.field), &r.field, outputs.contains(&r.field));
597    }
598    issues
599}