Skip to main content

eui_core/host/
query.rs

1//! VAPP-189 (S1): THE records query, the Rust twin of
2//! `packages/exponential-ui/src/host/query.ts`, locked by
3//! `fixtures/records-query.json` (`parseCases`, `sourceCases`, `jsonCases`,
4//! `valueCases`, `expandCases`; `bucketCases` lock [`crate::dates`]). A
5//! binding's URL sugar (`records:<kind>?q`) parses INTO the structured
6//! [`RecordsQuery`]; [`records_value`] evaluates it over the rows of any
7//! store. The desktop host calls it directly; the Swift and Compose SDKs
8//! reach it through `exponential-ui-ffi` (`parse_records_query_json`,
9//! `parse_records_json`, `parse_records_source_json`, `records_value_json`).
10//!
11//! VAPP-190 (Q1): the JSON form ([`parse_records_json`], a package's
12//! `queries`, the `q=<json>` param): `group` (any field, `author`, date
13//! buckets), `aggregate`, `running`, `before`, `in` / `between` and `or`.
14
15use std::borrow::Cow;
16use std::cmp::Ordering;
17use std::collections::{BTreeMap, HashMap};
18
19use rustc_hash::FxHashMap;
20use serde::{Deserialize, Serialize};
21use serde_json::{json, Map, Value};
22
23use super::signature::canonical_json;
24use super::sources::{parse_source, source_with_params, ParsedSource};
25use super::{js_trim, utf16_cmp};
26use crate::dates::{date_bucket, days_in_month, weekday_of, DateBucket, DATE_BUCKETS};
27use crate::json::{number, number_to_string};
28
29/// The binding scheme: `records:<kind>[?query]`.
30pub const RECORDS_SCHEME: &str = "records";
31/// The URL sugar's defaults and caps (catalog/host.json `package.records.query`).
32pub const RECORDS_SORT_DEFAULT: &str = "createdAt";
33pub const RECORDS_ORDER_DEFAULT: &str = "desc";
34pub const RECORDS_LIMIT_DEFAULT: u32 = 100;
35pub const RECORDS_LIMIT_MAX: u32 = 500;
36/// Query keys with a meaning of their own; never a field filter.
37pub const RECORDS_RESERVED_PARAMS: &[&str] = &["app", "before", "by", "expand", "limit", "mine", "month", "order", "q", "query", "sort", "sum", "user"];
38/// The keys every value row carries next to its data (a data key never shadows one).
39pub const RECORDS_ROW_KEYS: &[&str] = &["id", "createdAt", "updatedAt", "createdBy", "authorId", "author"];
40/// The filter operators: `eq` is the bare key, the others a `.<op>` suffix
41/// (`in` and `between` take comma-separated values).
42pub const RECORDS_FILTER_OPS: &[&str] = &["eq", "gt", "gte", "lt", "lte", "in", "between"];
43/// The JSON form's spellings of the operators.
44pub const RECORDS_OP_ALIASES: &[(&str, &str)] = &[("=", "eq"), ("==", "eq"), (">", "gt"), (">=", "gte"), ("<", "lt"), ("<=", "lte")];
45pub const RECORDS_AGGREGATE_OPS: &[&str] = &["sum", "avg", "min", "max", "count", "countDistinct", "first", "last"];
46pub const RECORDS_RUNNING_OPS: &[&str] = &["sum", "count", "avg", "min", "max"];
47/// Names an aggregate may not take (a group's and a total's own keys).
48pub const RECORDS_AGGREGATE_RESERVED: &[&str] = &["count", "sum", "running", "rows"];
49/// The month view's names (en-US, the same ×4).
50pub const RECORDS_WEEKDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
51pub const RECORDS_MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum RecordsFilterOp {
56    Eq,
57    Gt,
58    Gte,
59    Lt,
60    Lte,
61    In,
62    Between,
63}
64
65impl RecordsFilterOp {
66    pub fn as_str(self) -> &'static str {
67        match self {
68            Self::Eq => "eq",
69            Self::Gt => "gt",
70            Self::Gte => "gte",
71            Self::Lt => "lt",
72            Self::Lte => "lte",
73            Self::In => "in",
74            Self::Between => "between",
75        }
76    }
77
78    /// An operator by name (`eq` … `between`) or its JSON spelling (`<`, `>=`, `==`…).
79    pub fn parse(text: &str) -> Option<Self> {
80        let name = RECORDS_OP_ALIASES.iter().find(|(alias, _)| *alias == text).map_or(text, |(_, op)| op);
81        match name {
82            "eq" => Some(Self::Eq),
83            "gt" => Some(Self::Gt),
84            "gte" => Some(Self::Gte),
85            "lt" => Some(Self::Lt),
86            "lte" => Some(Self::Lte),
87            "in" => Some(Self::In),
88            "between" => Some(Self::Between),
89            _ => None,
90        }
91    }
92
93    /// The suffix operators (`<field>.<op>`); `eq` is the bare key.
94    fn suffix(text: &str) -> Option<Self> {
95        match text {
96            "gt" => Some(Self::Gt),
97            "gte" => Some(Self::Gte),
98            "lt" => Some(Self::Lt),
99            "lte" => Some(Self::Lte),
100            "in" => Some(Self::In),
101            "between" => Some(Self::Between),
102            _ => None,
103        }
104    }
105
106    fn is_list(self) -> bool {
107        matches!(self, Self::In | Self::Between)
108    }
109}
110
111/// A condition's value: one text, or a list (`in`; `between` = [low, high]).
112#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
113#[serde(untagged)]
114pub enum RecordsFilterValue {
115    One(String),
116    Many(Vec<String>),
117}
118
119/// One condition: `eq` / `in` compare as text, the ranges (`between`
120/// inclusive) as numbers when both sides parse, else as text.
121#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
122pub struct RecordsFilter {
123    pub field: String,
124    pub op: RecordsFilterOp,
125    pub value: RecordsFilterValue,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
129#[serde(rename_all = "lowercase")]
130pub enum RecordsOrder {
131    Asc,
132    Desc,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
136pub struct RecordsSortKey {
137    pub field: String,
138    pub order: RecordsOrder,
139}
140
141/// `by=<dateField>&month=YYYY-MM`: the rows of one month, every day listed.
142#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
143pub struct RecordsMonthView {
144    pub field: String,
145    /// `YYYY-MM`; `None` = the month of `now`.
146    pub key: Option<String>,
147}
148
149/// One group-by key: a field (`author` = the author, by name), or a date
150/// field cut into buckets. The group's column is named `field`.
151#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
152pub struct RecordsGroupKey {
153    pub field: String,
154    pub bucket: Option<DateBucket>,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase")]
159pub enum RecordsAggregateOp {
160    Sum,
161    Avg,
162    Min,
163    Max,
164    Count,
165    CountDistinct,
166    First,
167    Last,
168}
169
170impl RecordsAggregateOp {
171    pub fn parse(text: &str) -> Option<Self> {
172        match text {
173            "sum" => Some(Self::Sum),
174            "avg" => Some(Self::Avg),
175            "min" => Some(Self::Min),
176            "max" => Some(Self::Max),
177            "count" => Some(Self::Count),
178            "countDistinct" => Some(Self::CountDistinct),
179            "first" => Some(Self::First),
180            "last" => Some(Self::Last),
181            _ => None,
182        }
183    }
184}
185
186/// `name: [op, field?]`: a column of every group (and of `totals`).
187#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
188pub struct RecordsAggregate {
189    pub name: String,
190    pub op: RecordsAggregateOp,
191    /// `None` = `count` of the rows.
192    pub field: Option<String>,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
196#[serde(rename_all = "lowercase")]
197pub enum RecordsRunningOp {
198    Sum,
199    Count,
200    Avg,
201    Min,
202    Max,
203}
204
205impl RecordsRunningOp {
206    pub fn parse(text: &str) -> Option<Self> {
207        match text {
208            "sum" => Some(Self::Sum),
209            "count" => Some(Self::Count),
210            "avg" => Some(Self::Avg),
211            "min" => Some(Self::Min),
212            "max" => Some(Self::Max),
213            _ => None,
214        }
215    }
216}
217
218/// A cumulative value of `field` over the groups (or rows, or month days) in order.
219#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
220pub struct RecordsRunning {
221    pub field: String,
222    pub op: RecordsRunningOp,
223}
224
225/// The rows whose `field` is before `date` are left out of the rows and
226/// groups, totalled into `before` and seed every running value.
227#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
228pub struct RecordsBefore {
229    pub field: String,
230    pub date: String,
231}
232
233/// The structured query.
234#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
235pub struct RecordsQuery {
236    /// The kind; `None` = no kind (the value is null).
237    pub from: Option<String>,
238    /// Another app's slug (the HOST resolves whose rows); `None` = the hosting app.
239    pub app: Option<String>,
240    /// Only the viewer's rows (by author: `authorId`, else `createdBy`).
241    pub mine: bool,
242    /// Only this user's rows (by author).
243    pub user: Option<String>,
244    /// Every condition holds; by field, then operator (the URL sugar).
245    #[serde(rename = "where")]
246    pub where_: Vec<RecordsFilter>,
247    /// VAPP-190: one of these alternatives holds too (each: every condition).
248    #[serde(default)]
249    pub or: Vec<Vec<RecordsFilter>>,
250    /// Sort keys in order; ties always fall back to the id.
251    pub sort: Vec<RecordsSortKey>,
252    pub limit: u32,
253    /// Σ of this field over the matches (before the limit).
254    pub sum: Option<String>,
255    pub month: Option<RecordsMonthView>,
256    /// VAPP-190: the group-by keys.
257    #[serde(default)]
258    pub group: Vec<RecordsGroupKey>,
259    /// VAPP-190: the aggregates, by name.
260    #[serde(default)]
261    pub aggregate: Vec<RecordsAggregate>,
262    /// VAPP-190: the running values, by field.
263    #[serde(default)]
264    pub running: Vec<RecordsRunning>,
265    #[serde(default)]
266    pub before: Option<RecordsBefore>,
267    /// eApps R5 (VAPP-219): ref fields whose target is inlined into every
268    /// row (`records.<kind>.refs`, one level).
269    #[serde(default)]
270    pub expand: Vec<String>,
271}
272
273impl Default for RecordsQuery {
274    fn default() -> Self {
275        RecordsQuery {
276            from: None,
277            app: None,
278            mine: false,
279            user: None,
280            where_: Vec::new(),
281            or: Vec::new(),
282            sort: vec![RecordsSortKey { field: RECORDS_SORT_DEFAULT.to_string(), order: RecordsOrder::Desc }],
283            limit: RECORDS_LIMIT_DEFAULT,
284            sum: None,
285            month: None,
286            group: Vec::new(),
287            aggregate: Vec::new(),
288            running: Vec::new(),
289            before: None,
290            expand: Vec::new(),
291        }
292    }
293}
294
295/// A problem with a JSON query, a JSON pointer into it.
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297pub struct RecordsQueryIssue {
298    pub path: String,
299    pub message: String,
300}
301
302/// JavaScript's `Number(string)`: trimmed, empty = 0, `0x` / `0o` / `0b`
303/// integers, `[+-]Infinity`, a decimal literal; anything else NaN.
304pub(crate) fn js_string_number(s: &str) -> f64 {
305    let t = js_trim(s);
306    if t.is_empty() {
307        return 0.0;
308    }
309    for (prefix, radix) in [("0x", 16), ("0X", 16), ("0o", 8), ("0O", 8), ("0b", 2), ("0B", 2)] {
310        if let Some(digits) = t.strip_prefix(prefix) {
311            if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
312                return f64::NAN;
313            }
314            return digits.chars().fold(0.0, |n, c| n * f64::from(radix) + f64::from(c.to_digit(radix).unwrap_or(0)));
315        }
316    }
317    let (sign, body) = match t.as_bytes()[0] {
318        b'-' => (-1.0, &t[1..]),
319        b'+' => (1.0, &t[1..]),
320        _ => (1.0, t),
321    };
322    if body == "Infinity" {
323        return sign * f64::INFINITY;
324    }
325    let b = body.as_bytes();
326    let mut i = 0;
327    let digits = |i: &mut usize| {
328        let start = *i;
329        while b.get(*i).is_some_and(u8::is_ascii_digit) {
330            *i += 1;
331        }
332        *i - start
333    };
334    let int = digits(&mut i);
335    let frac = if b.get(i) == Some(&b'.') {
336        i += 1;
337        digits(&mut i)
338    } else {
339        0
340    };
341    if int == 0 && frac == 0 {
342        return f64::NAN;
343    }
344    if matches!(b.get(i), Some(b'e' | b'E')) {
345        i += 1;
346        if matches!(b.get(i), Some(b'+' | b'-')) {
347            i += 1;
348        }
349        if digits(&mut i) == 0 {
350            return f64::NAN;
351        }
352    }
353    if i != b.len() {
354        return f64::NAN;
355    }
356    body.parse::<f64>().map(|n| sign * n).unwrap_or(f64::NAN)
357}
358
359/// `YYYY-MM` with a month 01–12.
360fn is_month_key(text: &str) -> bool {
361    let b = text.as_bytes();
362    b.len() == 7 && b[..4].iter().all(u8::is_ascii_digit) && b[4] == b'-' && matches!(&text[5..7], "01" | "02" | "03" | "04" | "05" | "06" | "07" | "08" | "09" | "10" | "11" | "12")
363}
364
365fn trimmed(v: Option<&str>) -> Option<String> {
366    v.map(js_trim).filter(|s| !s.is_empty()).map(str::to_string)
367}
368
369/// Query params (as [`super::parse_source`] gives them) → the query. `kind`
370/// = the source's own name (`records:<kind>`). Unknown keys are field
371/// filters. VAPP-190: `q=<json>` = the
372/// JSON form ([`parse_records_json`]; the other params are ignored).
373pub fn parse_records_query(params: &BTreeMap<String, String>, kind: Option<&str>) -> RecordsQuery {
374    let from = trimmed(kind);
375    if let Some(q) = params.get("q").filter(|q| !js_trim(q).is_empty()) {
376        let json = serde_json::from_str::<Value>(q).ok().filter(Value::is_object).unwrap_or_else(|| json!({}));
377        return parse_records_json(&json, from.as_deref()).0;
378    }
379    let limit_raw = params.get("limit").map_or(f64::NAN, |l| js_string_number(l));
380    let mut keys: Vec<&String> = params.keys().collect();
381    keys.sort_by(|a, b| utf16_cmp(a, b));
382    let mut where_ = Vec::new();
383    for key in keys {
384        if key.is_empty() || RECORDS_RESERVED_PARAMS.contains(&key.as_str()) {
385            continue;
386        }
387        let value = params[key].clone();
388        match key.rfind('.').and_then(|dot| RecordsFilterOp::suffix(&key[dot + 1..]).map(|op| (dot, op))) {
389            Some((dot, op)) if dot > 0 => {
390                let value = if op.is_list() { RecordsFilterValue::Many(value.split(',').map(str::to_string).collect()) } else { RecordsFilterValue::One(value) };
391                where_.push(RecordsFilter { field: key[..dot].to_string(), op, value });
392            }
393            _ => where_.push(RecordsFilter { field: key.clone(), op: RecordsFilterOp::Eq, value: RecordsFilterValue::One(value) }),
394        }
395    }
396    where_.sort_by(|a, b| utf16_cmp(&a.field, &b.field).then_with(|| utf16_cmp(a.op.as_str(), b.op.as_str())));
397    let order = match params.get("order").map(String::as_str) {
398        Some("asc") => RecordsOrder::Asc,
399        Some("desc") => RecordsOrder::Desc,
400        _ if RECORDS_ORDER_DEFAULT == "asc" => RecordsOrder::Asc,
401        _ => RecordsOrder::Desc,
402    };
403    let limit = if limit_raw.is_finite() && limit_raw.fract() == 0.0 && limit_raw > 0.0 { limit_raw.min(f64::from(RECORDS_LIMIT_MAX)) as u32 } else { RECORDS_LIMIT_DEFAULT };
404    let by = trimmed(params.get("by").map(String::as_str));
405    let before = trimmed(params.get("before").map(String::as_str));
406    RecordsQuery {
407        from,
408        app: trimmed(params.get("app").map(String::as_str)),
409        mine: matches!(params.get("mine").map(String::as_str), Some("1" | "true")),
410        user: trimmed(params.get("user").map(String::as_str)),
411        where_,
412        sort: vec![RecordsSortKey { field: trimmed(params.get("sort").map(String::as_str)).unwrap_or_else(|| RECORDS_SORT_DEFAULT.to_string()), order }],
413        limit,
414        sum: trimmed(params.get("sum").map(String::as_str)),
415        before: before.map(|date| RecordsBefore { field: by.clone().unwrap_or_else(|| RECORDS_SORT_DEFAULT.to_string()), date }),
416        month: by.map(|field| RecordsMonthView { field, key: params.get("month").filter(|m| is_month_key(m)).cloned() }),
417        expand: {
418            let mut out: Vec<String> = Vec::new();
419            for f in params.get("expand").map(String::as_str).unwrap_or("").split(',').map(js_trim).filter(|f| !f.is_empty()) {
420                if !out.iter().any(|o| o == f) {
421                    out.push(f.to_string());
422                }
423            }
424            out
425        },
426        ..RecordsQuery::default()
427    }
428}
429
430/// `records:<kind>?…` → the query; `None` = not a records source.
431pub fn parse_records_source(source: &ParsedSource) -> Option<RecordsQuery> {
432    (source.scheme == RECORDS_SCHEME).then(|| parse_records_query(&source.params, Some(&source.name)))
433}
434
435/// A JSON scalar as the query's text (a number as JS prints it); `None` = not a scalar.
436fn scalar_text(v: &Value) -> Option<String> {
437    match v {
438        Value::Null => Some(String::new()),
439        Value::String(s) => Some(s.clone()),
440        Value::Bool(b) => Some(b.to_string()),
441        Value::Number(n) => Some(number_to_string(n.as_f64().unwrap_or(0.0))),
442        _ => None,
443    }
444}
445
446fn issue(issues: &mut Vec<RecordsQueryIssue>, path: impl Into<String>, message: impl Into<String>) {
447    issues.push(RecordsQueryIssue { path: path.into(), message: message.into() });
448}
449
450/// `[field, op, value…]` → a condition.
451fn parse_condition(entry: &Value, at: &str, issues: &mut Vec<RecordsQueryIssue>) -> Option<RecordsFilter> {
452    let Some(list) = entry.as_array().filter(|l| l.len() >= 3) else {
453        issue(issues, at, "a condition is [field, op, value]");
454        return None;
455    };
456    let Some(field) = list[0].as_str().map(js_trim).filter(|f| !f.is_empty()) else {
457        issue(issues, format!("{at}/0"), "a condition's field is a non-empty string");
458        return None;
459    };
460    let Some(op) = list[1].as_str().and_then(RecordsFilterOp::parse) else {
461        issue(issues, format!("{at}/1"), format!("unknown operator {}", crate::json::to_js_string(&list[1])));
462        return None;
463    };
464    let rest = &list[2..];
465    if op == RecordsFilterOp::In {
466        let values: Option<Vec<String>> = match rest {
467            [Value::Array(items)] => items.iter().map(scalar_text).collect(),
468            _ => None,
469        };
470        let Some(values) = values else {
471            issue(issues, format!("{at}/2"), "in takes one list of values");
472            return None;
473        };
474        return Some(RecordsFilter { field: field.to_string(), op, value: RecordsFilterValue::Many(values) });
475    }
476    let values: Option<Vec<String>> = rest.iter().map(scalar_text).collect();
477    let want = if op == RecordsFilterOp::Between { 2 } else { 1 };
478    let Some(mut values) = values.filter(|v| v.len() == want) else {
479        issue(issues, format!("{at}/2"), if op == RecordsFilterOp::Between { "between takes a low and a high value".to_string() } else { format!("{} takes one value", op.as_str()) });
480        return None;
481    };
482    let value = if op == RecordsFilterOp::Between { RecordsFilterValue::Many(values) } else { RecordsFilterValue::One(values.remove(0)) };
483    Some(RecordsFilter { field: field.to_string(), op, value })
484}
485
486const QUERY_KEYS: &[&str] = &["from", "app", "mine", "user", "where", "sort", "limit", "sum", "month", "group", "aggregate", "running", "before", "expand"];
487
488fn sorted_keys(map: &Map<String, Value>) -> Vec<&String> {
489    let mut keys: Vec<&String> = map.keys().collect();
490    keys.sort_by(|a, b| utf16_cmp(a, b));
491    keys
492}
493
494/// VAPP-190: the JSON form (a package's `queries`, the `q` param) → the
495/// query and every problem with it (a problem drops the part, the rest
496/// still runs). `kind` (a source's own) wins over `from`.
497pub fn parse_records_json(json: &Value, kind: Option<&str>) -> (RecordsQuery, Vec<RecordsQueryIssue>) {
498    let mut issues = Vec::new();
499    let mut query = RecordsQuery::default();
500    let Some(obj) = json.as_object() else {
501        issue(&mut issues, "", "a query is an object");
502        query.from = trimmed(kind);
503        return (query, issues);
504    };
505    for key in sorted_keys(obj) {
506        if !QUERY_KEYS.contains(&key.as_str()) && !key.starts_with('$') {
507            issue(&mut issues, format!("/{key}"), format!("unknown key {key}"));
508        }
509    }
510    let str_of = |key: &str, issues: &mut Vec<RecordsQueryIssue>| -> Option<String> {
511        match obj.get(key) {
512            None | Some(Value::Null) => None,
513            Some(Value::String(s)) => trimmed(Some(s)),
514            Some(_) => {
515                issue(issues, format!("/{key}"), format!("{key} is a string"));
516                None
517            }
518        }
519    };
520    query.from = match trimmed(kind) {
521        Some(k) => Some(k),
522        None => str_of("from", &mut issues),
523    };
524    query.app = str_of("app", &mut issues);
525    query.user = str_of("user", &mut issues);
526    query.sum = str_of("sum", &mut issues);
527    match obj.get("mine") {
528        None => {}
529        Some(Value::Bool(b)) => query.mine = *b,
530        Some(_) => issue(&mut issues, "/mine", "mine is a boolean"),
531    }
532    match obj.get("where") {
533        None => {}
534        Some(Value::Array(list)) => {
535            for (i, entry) in list.iter().enumerate() {
536                let at = format!("/where/{i}");
537                if let Some(alt) = entry.as_object() {
538                    let Some(alternatives) = alt.get("or").and_then(Value::as_array).filter(|_| alt.len() == 1) else {
539                        issue(&mut issues, at, "an alternative is {or: [[condition…]…]}");
540                        continue;
541                    };
542                    if !query.or.is_empty() {
543                        issue(&mut issues, at, "one or per query");
544                        continue;
545                    }
546                    for (j, alt) in alternatives.iter().enumerate() {
547                        let Some(conds) = alt.as_array() else {
548                            issue(&mut issues, format!("{at}/or/{j}"), "an alternative is a list of conditions");
549                            continue;
550                        };
551                        let parsed: Vec<RecordsFilter> = conds.iter().enumerate().filter_map(|(k, c)| parse_condition(c, &format!("{at}/or/{j}/{k}"), &mut issues)).collect();
552                        query.or.push(parsed);
553                    }
554                    continue;
555                }
556                if let Some(parsed) = parse_condition(entry, &at, &mut issues) {
557                    query.where_.push(parsed);
558                }
559            }
560        }
561        Some(_) => issue(&mut issues, "/where", "where is a list of conditions"),
562    }
563    if let Some(sort) = obj.get("sort") {
564        let single = sort.is_string();
565        let list: Option<Vec<Value>> = if single { Some(vec![sort.clone()]) } else { sort.as_array().cloned() };
566        match list {
567            None => issue(&mut issues, "/sort", "sort is a field or a list of [field, order]"),
568            Some(list) => {
569                let mut keys = Vec::new();
570                for (i, entry) in list.iter().enumerate() {
571                    let asc = Value::String("asc".into());
572                    let (name, order): (Option<&Value>, Option<&Value>) = match entry {
573                        Value::String(_) => (Some(entry), Some(&asc)),
574                        Value::Array(pair) => (pair.first(), if pair.len() > 1 { pair.get(1) } else { Some(&asc) }),
575                        _ => (None, None),
576                    };
577                    let name = name.and_then(Value::as_str).map(js_trim).filter(|n| !n.is_empty());
578                    let order = match order.and_then(Value::as_str) {
579                        Some("asc") => Some(RecordsOrder::Asc),
580                        Some("desc") => Some(RecordsOrder::Desc),
581                        _ => None,
582                    };
583                    match (name, order) {
584                        (Some(name), Some(order)) => keys.push(RecordsSortKey { field: name.to_string(), order }),
585                        _ => issue(&mut issues, if single { "/sort".to_string() } else { format!("/sort/{i}") }, "a sort key is field or [field, asc|desc]"),
586                    }
587                }
588                if !keys.is_empty() {
589                    query.sort = keys;
590                }
591            }
592        }
593    }
594    if let Some(limit) = obj.get("limit") {
595        let n = match limit {
596            Value::Number(n) => n.as_f64().unwrap_or(f64::NAN),
597            Value::String(s) if !js_trim(s).is_empty() => js_string_number(s),
598            _ => f64::NAN,
599        };
600        if n.is_finite() && n.fract() == 0.0 && n >= 1.0 {
601            query.limit = n.min(f64::from(RECORDS_LIMIT_MAX)) as u32;
602        } else {
603            issue(&mut issues, "/limit", "limit is a positive integer");
604        }
605    }
606    match obj.get("month") {
607        None | Some(Value::Null) => {}
608        Some(m) => {
609            let field = m.get("field").and_then(Value::as_str).map(js_trim).filter(|f| !f.is_empty());
610            let key = m.get("key");
611            match (m.as_object(), field) {
612                (Some(_), Some(field)) if matches!(key, None | Some(Value::Null) | Some(Value::String(_))) => {
613                    query.month = Some(RecordsMonthView { field: field.to_string(), key: key.and_then(Value::as_str).filter(|k| is_month_key(k)).map(str::to_string) });
614                }
615                _ => issue(&mut issues, "/month", "month is {field, key?}"),
616            }
617        }
618    }
619    match obj.get("group") {
620        None => {}
621        Some(Value::Array(list)) => {
622            for (i, entry) in list.iter().enumerate() {
623                let at = format!("/group/{i}");
624                let key = match entry {
625                    Value::String(s) if !js_trim(s).is_empty() => Some(RecordsGroupKey { field: js_trim(s).to_string(), bucket: None }),
626                    Value::Object(o) if o.len() == 2 => {
627                        let field = o.get("bucket").and_then(Value::as_str).map(js_trim).filter(|f| !f.is_empty());
628                        let bucket = o.get("by").and_then(Value::as_str).and_then(DateBucket::parse);
629                        field.zip(bucket).map(|(field, bucket)| RecordsGroupKey { field: field.to_string(), bucket: Some(bucket) })
630                    }
631                    _ => None,
632                };
633                let Some(key) = key else {
634                    issue(&mut issues, at, format!("a group key is a field or {{bucket: field, by: {}}}", DATE_BUCKETS.join("|")));
635                    continue;
636                };
637                if query.group.iter().any(|g| g.field == key.field || (key.field == "author" && g.field == "authorId") || (key.field == "authorId" && g.field == "author")) {
638                    issue(&mut issues, at, format!("duplicate group {}", key.field));
639                    continue;
640                }
641                query.group.push(key);
642            }
643        }
644        Some(_) => issue(&mut issues, "/group", "group is a list of keys"),
645    }
646    match obj.get("aggregate") {
647        None => {}
648        Some(Value::Object(aggs)) => {
649            for name in sorted_keys(aggs) {
650                let def = &aggs[name];
651                let at = format!("/aggregate/{name}");
652                if js_trim(name).is_empty() || RECORDS_AGGREGATE_RESERVED.contains(&name.as_str()) || query.group.iter().any(|g| g.field == *name || (g.field == "author" && name == "authorId")) {
653                    issue(&mut issues, at, format!("the name {name} is taken"));
654                    continue;
655                }
656                let list = def.as_array();
657                let op = list.and_then(|l| l.first()).and_then(Value::as_str).and_then(RecordsAggregateOp::parse);
658                let len = list.map_or(0, Vec::len);
659                let field = list.filter(|l| l.len() > 1).and_then(|l| l[1].as_str()).map(js_trim).filter(|f| !f.is_empty());
660                match op {
661                    Some(op) if len <= 2 && !(len > 1 && field.is_none()) && (op == RecordsAggregateOp::Count || field.is_some()) => {
662                        query.aggregate.push(RecordsAggregate { name: name.clone(), op, field: field.map(str::to_string) });
663                    }
664                    _ => issue(&mut issues, at, format!("an aggregate is [{}, field] (count: the field is optional)", RECORDS_AGGREGATE_OPS.join("|"))),
665                }
666            }
667        }
668        Some(_) => issue(&mut issues, "/aggregate", "aggregate is {name: [op, field?]}"),
669    }
670    match obj.get("running") {
671        None => {}
672        Some(Value::Object(running)) => {
673            for name in sorted_keys(running) {
674                match running[name].as_str().and_then(RecordsRunningOp::parse) {
675                    Some(op) if !js_trim(name).is_empty() => query.running.push(RecordsRunning { field: name.clone(), op }),
676                    _ => issue(&mut issues, format!("/running/{name}"), format!("a running value is {{field: {}}}", RECORDS_RUNNING_OPS.join("|"))),
677                }
678            }
679        }
680        Some(_) => issue(&mut issues, "/running", "running is {field: op}"),
681    }
682    match obj.get("before") {
683        None | Some(Value::Null) => {}
684        Some(Value::String(date)) => {
685            let implied = query.group.iter().find(|g| g.bucket.is_some()).map(|g| g.field.clone()).or_else(|| query.month.as_ref().map(|m| m.field.clone())).unwrap_or_else(|| RECORDS_SORT_DEFAULT.to_string());
686            query.before = trimmed(Some(date)).map(|date| RecordsBefore { field: implied, date });
687        }
688        Some(b) => {
689            let field = b.as_object().and_then(|o| o.get("field")).and_then(Value::as_str).map(js_trim).filter(|f| !f.is_empty());
690            match (field, b.get("date").and_then(Value::as_str)) {
691                (Some(field), Some(date)) => query.before = trimmed(Some(date)).map(|date| RecordsBefore { field: field.to_string(), date }),
692                _ => issue(&mut issues, "/before", "before is a date or {field, date}"),
693            }
694        }
695    }
696    // eApps R5 (VAPP-219): the ref fields to inline.
697    match obj.get("expand") {
698        None => {}
699        Some(Value::Array(list)) => {
700            for (i, entry) in list.iter().enumerate() {
701                match entry.as_str().map(js_trim).filter(|f| !f.is_empty()) {
702                    None => issue(&mut issues, format!("/expand/{i}"), "an expanded field is a non-empty string"),
703                    Some(f) if query.expand.iter().any(|e| e == f) => issue(&mut issues, format!("/expand/{i}"), format!("duplicate expand {f}")),
704                    Some(f) => query.expand.push(f.to_string()),
705                }
706            }
707        }
708        Some(_) => issue(&mut issues, "/expand", "expand is a list of ref fields"),
709    }
710    (query, issues)
711}
712
713/// `${name}` in every string of a JSON query → `params[name]` ("" when unset).
714pub fn fill_query_templates(value: &Value, params: &BTreeMap<String, String>) -> Value {
715    match value {
716        Value::String(s) => {
717            let mut out = String::new();
718            let mut rest = s.as_str();
719            while let Some(start) = rest.find("${") {
720                let Some(len) = rest[start + 2..].find('}') else { break };
721                out.push_str(&rest[..start]);
722                let name = js_trim(&rest[start + 2..start + 2 + len]);
723                out.push_str(params.get(name).map_or("", String::as_str));
724                rest = &rest[start + 2 + len + 1..];
725            }
726            out.push_str(rest);
727            Value::String(out)
728        }
729        Value::Array(items) => Value::Array(items.iter().map(|v| fill_query_templates(v, params)).collect()),
730        Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), fill_query_templates(v, params))).collect()),
731        other => other.clone(),
732    }
733}
734
735/// VAPP-190: a package binding `<scheme>:<name>?query=<id>&<params>` naming
736/// one of the package's `queries` → the same source with that query, its
737/// `${param}` templates filled from the other params, as `q=<canonical
738/// JSON>` (a `kind` param stays). Anything else is returned as written.
739pub fn expand_query_source(source: &str, queries: Option<&Value>) -> String {
740    let Some(parsed) = parse_source(source) else { return source.to_string() };
741    let Some(id) = parsed.params.get("query") else { return source.to_string() };
742    let Some(query) = queries.and_then(Value::as_object).and_then(|q| q.get(id)) else { return source.to_string() };
743    let mut params = parsed.params.clone();
744    params.remove("query");
745    let filled = fill_query_templates(query, &params);
746    let base = js_trim(source);
747    let head = base.split_once('?').map_or(base, |(h, _)| h);
748    source_with_params(head, Some(&json!({ "q": canonical_json(&filled) })))
749}
750
751/// One stored record as a host hands it to the evaluator (borrowed).
752#[derive(Debug, Clone, Copy, PartialEq)]
753pub struct RecordRowRef<'a> {
754    pub id: &'a str,
755    pub kind: &'a str,
756    pub data: &'a Value,
757    pub created_by: Option<&'a str>,
758    /// Whose record it is (#VAPP-187); `None` = `created_by`.
759    pub author_id: Option<&'a str>,
760    pub created_at: &'a str,
761    pub updated_at: &'a str,
762}
763
764impl<'a> RecordRowRef<'a> {
765    /// The row's author: `authorId`, else `createdBy`.
766    pub fn author(&self) -> Option<&'a str> {
767        self.author_id.or(self.created_by)
768    }
769}
770
771/// One stored record, owned (the FFI's rows JSON).
772#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
773#[serde(rename_all = "camelCase")]
774pub struct RecordRow {
775    pub id: String,
776    pub kind: String,
777    #[serde(default)]
778    pub data: Value,
779    #[serde(default)]
780    pub created_by: Option<String>,
781    #[serde(default)]
782    pub author_id: Option<String>,
783    pub created_at: String,
784    pub updated_at: String,
785}
786
787impl RecordRow {
788    pub fn as_ref(&self) -> RecordRowRef<'_> {
789        RecordRowRef {
790            id: &self.id,
791            kind: &self.kind,
792            data: &self.data,
793            created_by: self.created_by.as_deref(),
794            author_id: self.author_id.as_deref(),
795            created_at: &self.created_at,
796            updated_at: &self.updated_at,
797        }
798    }
799}
800
801/// What [`records_value`] needs besides the rows.
802#[derive(Debug, Clone, Copy)]
803pub struct RecordsContext<'a> {
804    /// The viewer's user id (`None` = signed out: `mine` matches nothing).
805    pub me: Option<&'a str>,
806    /// ISO 8601 (UTC): the clock (computed fields).
807    pub now: &'a str,
808    /// `YYYY-MM-DD`: the host's calendar day in the team zone (team-zone.json):
809    /// the month view's `today` and default month; `None` = `now`'s UTC day.
810    pub today: Option<&'a str>,
811    /// user id → display name (`author`; "" when unknown).
812    pub names: &'a HashMap<String, String>,
813    /// VAPP-214 (Q3): the queried kind's computed fields (`records.<kind>.computed`:
814    /// name → CEL), evaluated on every row of the kind before the query.
815    pub computed: Option<&'a BTreeMap<String, String>>,
816    /// eApps R5 (VAPP-219): the queried kind's refs (`records.<kind>.refs`:
817    /// field → `{kind}` | `"issue"` | `"member"`), what `expand` follows.
818    pub refs: Option<&'a Value>,
819    /// eApps R5: identifier → the issue summary an `issue` ref expands to.
820    pub issues: Option<&'a Map<String, Value>>,
821}
822
823/// eApps R5 (VAPP-219): a package's refs of `kind` (`records.<kind>.refs`,
824/// the well-formed ones); `None` = none. What a host passes as `refs`.
825pub fn kind_refs(records: Option<&Value>, kind: Option<&str>) -> Option<Value> {
826    let def = records?.as_object()?.get(kind?)?.as_object()?;
827    let mut out = Map::new();
828    for (name, target) in def.get("refs")?.as_object()? {
829        match target {
830            Value::String(t) if t == "issue" || t == "member" => {
831                out.insert(name.clone(), target.clone());
832            }
833            Value::Object(o) => {
834                if let Some(k) = o.get("kind").and_then(Value::as_str).filter(|k| !k.is_empty()) {
835                    out.insert(name.clone(), json!({ "kind": k }));
836                }
837            }
838            _ => {}
839        }
840    }
841    (!out.is_empty()).then_some(Value::Object(out))
842}
843
844/// A record as a value row: its data and the row keys (`author` = the name).
845fn value_row(row: &RecordRowRef, names: &HashMap<String, String>) -> Map<String, Value> {
846    let mut obj: Map<String, Value> = row.data.as_object().cloned().unwrap_or_default();
847    obj.insert("id".into(), json!(row.id));
848    obj.insert("createdAt".into(), json!(row.created_at));
849    obj.insert("updatedAt".into(), json!(row.updated_at));
850    obj.insert("createdBy".into(), json!(row.created_by));
851    obj.insert("authorId".into(), json!(row.author()));
852    let author = row.author().and_then(|a| names.get(a)).cloned().unwrap_or_default();
853    obj.insert("author".into(), json!(author));
854    obj
855}
856
857/// eApps R5 (VAPP-219): one ref's value inlined: a record of the target kind
858/// among the rows (as a value row, its own refs not followed), an issue
859/// summary from `ctx.issues`, a member `{id, name}` from `ctx.names`; a
860/// dangling ref and an empty one read null.
861fn expanded_ref(target: &Value, value: Option<&Value>, by_id: &HashMap<&str, &RecordRowRef>, ctx: &RecordsContext) -> Value {
862    let id = match value {
863        Some(Value::String(s)) => s.clone(),
864        Some(Value::Number(n)) => number_to_string(n.as_f64().unwrap_or(0.0)),
865        _ => String::new(),
866    };
867    if id.is_empty() {
868        return Value::Null;
869    }
870    match target {
871        Value::String(t) if t == "issue" => ctx.issues.and_then(|m| m.get(&id)).cloned().unwrap_or(Value::Null),
872        Value::String(t) if t == "member" => ctx.names.get(&id).map_or(Value::Null, |name| json!({ "id": id, "name": name })),
873        _ => {
874            let kind = target.get("kind").and_then(Value::as_str).unwrap_or("");
875            by_id.get(id.as_str()).filter(|r| r.kind == kind).map_or(Value::Null, |r| Value::Object(value_row(r, ctx.names)))
876        }
877    }
878}
879
880/// VAPP-214 (Q3): at most this many computed fields per kind.
881pub const RECORDS_COMPUTED_MAX: usize = 16;
882/// The row keys a computed field reads next to its kind's fields (`author`, a name, never).
883pub const RECORDS_COMPUTED_ROW_KEYS: [&str; 5] = ["id", "createdAt", "updatedAt", "createdBy", "authorId"];
884
885/// VAPP-214 (Q3): a package's computed fields of `kind` (`records.<kind>.computed`,
886/// the string ones); `None` = none. What a host passes as `computed`.
887pub fn kind_computed(records: Option<&Value>, kind: Option<&str>) -> Option<BTreeMap<String, String>> {
888    let def = records?.as_object()?.get(kind?)?.as_object()?;
889    let out: BTreeMap<String, String> = def.get("computed")?.as_object()?.iter().filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))).collect();
890    (!out.is_empty()).then_some(out)
891}
892
893/// VAPP-214 (Q3): the data of every row of `kind` with its computed fields
894/// written in (`None` for other kinds' rows: they stay as they are). A field
895/// reads the row's data and the row keys by name (a missing one = null); it
896/// never reads another computed field (the checker refuses it). A failing
897/// or unparsable expression reads null. Evaluated in UTC with the query's
898/// `now`, like the query's own buckets. Never stored.
899pub fn computed_data(rows: &[RecordRowRef], kind: &str, computed: &BTreeMap<String, String>, now: &str) -> Vec<Option<Value>> {
900    let fields: Vec<(&String, Option<crate::cel::Node>, Vec<String>)> = computed
901        .iter()
902        .map(|(name, source)| {
903            let ast = crate::cel::parse_expression(source).ok();
904            let reads = ast.as_ref().map(crate::cel::expression_names).unwrap_or_default();
905            (name, ast, reads)
906        })
907        .collect();
908    let env = crate::cel::ExpressionEnv { now: Some(crate::cel::parse_timestamp(now).unwrap_or(0.0)), offset: None, max_steps: None };
909    rows.iter()
910        .map(|row| {
911            if row.kind != kind {
912                return None;
913            }
914            let mut vars: Map<String, Value> = row.data.as_object().cloned().unwrap_or_default();
915            vars.insert("id".into(), Value::String(row.id.to_string()));
916            vars.insert("createdAt".into(), Value::String(row.created_at.to_string()));
917            vars.insert("updatedAt".into(), Value::String(row.updated_at.to_string()));
918            vars.insert("createdBy".into(), row.created_by.map_or(Value::Null, |s| Value::String(s.to_string())));
919            vars.insert("authorId".into(), row.author().map_or(Value::Null, |s| Value::String(s.to_string())));
920            let mut data: Map<String, Value> = row.data.as_object().cloned().unwrap_or_default();
921            for (name, ast, reads) in &fields {
922                for r in reads {
923                    if !vars.contains_key(r) {
924                        vars.insert(r.clone(), Value::Null);
925                    }
926                }
927                let value = ast.as_ref().and_then(|ast| crate::cel::evaluate_node(ast, &vars, &env).ok()).unwrap_or(Value::Null);
928                data.insert((*name).clone(), value);
929            }
930            Some(Value::Object(data))
931        })
932        .collect()
933}
934
935/// A field as a JS value (`undefined` = `Missing`).
936#[derive(Clone, Copy)]
937enum Field<'a> {
938    Text(&'a str),
939    Json(&'a Value),
940    Missing,
941}
942
943fn num_text(s: &str) -> Option<f64> {
944    if js_trim(s).is_empty() {
945        return None;
946    }
947    Some(js_string_number(s)).filter(|f| f.is_finite())
948}
949
950fn num(v: &Field) -> Option<f64> {
951    match v {
952        Field::Json(Value::Number(n)) => n.as_f64().filter(|f| f.is_finite()),
953        Field::Json(Value::String(s)) => num_text(s),
954        Field::Text(s) => num_text(s),
955        Field::Json(_) | Field::Missing => None,
956    }
957}
958
959fn num_value(v: Option<&Value>) -> Option<f64> {
960    v.and_then(|v| num(&Field::Json(v)))
961}
962
963/// VAPP-202: the field as text, BORROWED when it is one (strings, the row
964/// keys); only numbers, booleans and objects allocate.
965fn text<'a>(v: &Field<'a>) -> Cow<'a, str> {
966    match *v {
967        Field::Missing | Field::Json(Value::Null) => Cow::Borrowed(""),
968        Field::Text(s) => Cow::Borrowed(s),
969        Field::Json(Value::String(s)) => Cow::Borrowed(s.as_str()),
970        Field::Json(Value::Bool(b)) => Cow::Borrowed(if *b { "true" } else { "false" }),
971        Field::Json(Value::Number(n)) => Cow::Owned(number_to_string(n.as_f64().unwrap_or(0.0))),
972        Field::Json(other) => Cow::Owned(canonical_json(other)),
973    }
974}
975
976/// The field as JSON (`undefined` = null).
977fn json_of(v: &Field) -> Value {
978    match v {
979        Field::Missing => Value::Null,
980        Field::Text(s) => Value::String((*s).to_string()),
981        Field::Json(v) => (*v).clone(),
982    }
983}
984
985/// VAPP-202: a field read ONCE for comparing (a sort key, a filter's bound):
986/// its number when it parses, else its text. [`cmp_keys`] = [`compare`]
987/// without parsing or stringifying per comparison.
988enum Key<'a> {
989    /// A number; the field stays for the text side of a mixed comparison.
990    Num(f64, Field<'a>),
991    Text(Cow<'a, str>),
992}
993
994impl<'a> Key<'a> {
995    fn of(v: Field<'a>) -> Self {
996        match num(&v) {
997            Some(n) => Key::Num(n, v),
998            None => Key::Text(text(&v)),
999        }
1000    }
1001
1002    /// A text key's text ("" for a number: [`Col::of`] only asks text keys).
1003    fn text_ref(&self) -> &str {
1004        match self {
1005            Key::Num(..) => "",
1006            Key::Text(t) => t,
1007        }
1008    }
1009
1010    fn text(&self) -> Cow<'_, str> {
1011        match self {
1012            Key::Num(_, f) => text(f),
1013            Key::Text(t) => Cow::Borrowed(t),
1014        }
1015    }
1016}
1017
1018/// Numbers when both parse, else text (UTF-16 code units, no locale).
1019fn cmp_keys(a: &Key, b: &Key) -> Ordering {
1020    if let (Key::Num(na, _), Key::Num(nb, _)) = (a, b) {
1021        return na.partial_cmp(nb).unwrap_or(Ordering::Equal);
1022    }
1023    utf16_cmp(&a.text(), &b.text())
1024}
1025
1026fn compare(a: &Field, b: &Field) -> Ordering {
1027    cmp_keys(&Key::of(*a), &Key::of(*b))
1028}
1029
1030/// VAPP-202: a condition with its bounds parsed once per query, not per row.
1031struct Cond<'q> {
1032    filter: &'q RecordsFilter,
1033    bounds: Vec<Key<'q>>,
1034}
1035
1036impl<'q> Cond<'q> {
1037    fn new(filter: &'q RecordsFilter) -> Self {
1038        let bounds = match (&filter.value, filter.op) {
1039            (_, RecordsFilterOp::Eq | RecordsFilterOp::In) => Vec::new(),
1040            (RecordsFilterValue::One(v), _) => vec![Key::of(Field::Text(v))],
1041            (RecordsFilterValue::Many(vs), _) => vs.iter().map(|v| Key::of(Field::Text(v))).collect(),
1042        };
1043        Cond { filter, bounds }
1044    }
1045
1046    fn holds(&self, row: &RecordRowRef) -> bool {
1047        let value = field(row, &self.filter.field);
1048        match self.filter.op {
1049            RecordsFilterOp::Eq => match &self.filter.value {
1050                RecordsFilterValue::One(v) => text(&value) == v.as_str(),
1051                RecordsFilterValue::Many(_) => false,
1052            },
1053            RecordsFilterOp::In => {
1054                let t = text(&value);
1055                match &self.filter.value {
1056                    RecordsFilterValue::One(v) => t == v.as_str(),
1057                    RecordsFilterValue::Many(vs) => vs.iter().any(|v| t == v.as_str()),
1058                }
1059            }
1060            RecordsFilterOp::Between => self.bounds.len() == 2 && {
1061                let k = Key::of(value);
1062                cmp_keys(&k, &self.bounds[0]).is_ge() && cmp_keys(&k, &self.bounds[1]).is_le()
1063            },
1064            op => {
1065                let [bound] = self.bounds.as_slice() else { return false };
1066                let c = cmp_keys(&Key::of(value), bound);
1067                match op {
1068                    RecordsFilterOp::Gt => c.is_gt(),
1069                    RecordsFilterOp::Gte => c.is_ge(),
1070                    RecordsFilterOp::Lt => c.is_lt(),
1071                    _ => c.is_le(),
1072                }
1073            }
1074        }
1075    }
1076}
1077
1078fn data_field<'a>(row: &RecordRowRef<'a>, name: &str) -> Field<'a> {
1079    match row.data.as_object().and_then(|o| o.get(name)) {
1080        Some(v) => Field::Json(v),
1081        None => Field::Missing,
1082    }
1083}
1084
1085static NULL: Value = Value::Null;
1086
1087/// A field as the query reads it: a row key (`authorId` = the author), else the data's.
1088fn field<'a>(row: &RecordRowRef<'a>, name: &str) -> Field<'a> {
1089    match name {
1090        "createdAt" => Field::Text(row.created_at),
1091        "updatedAt" => Field::Text(row.updated_at),
1092        "id" => Field::Text(row.id),
1093        "createdBy" => row.created_by.map_or(Field::Json(&NULL), Field::Text),
1094        "authorId" => row.author().map_or(Field::Json(&NULL), Field::Text),
1095        _ => data_field(row, name),
1096    }
1097}
1098
1099fn parse_month(key: &str) -> (i64, i64) {
1100    let (y, m) = key.split_once('-').unwrap_or(("1970", "1"));
1101    (y.parse().unwrap_or(1970), m.parse::<i64>().unwrap_or(1).clamp(1, 12))
1102}
1103
1104/// `YYYY-MM` ± `by` months.
1105pub fn shift_month(key: &str, by: i64) -> String {
1106    let (y, m) = parse_month(key);
1107    let index = y * 12 + (m - 1) + by;
1108    format!("{}-{:02}", index.div_euclid(12), index.rem_euclid(12) + 1)
1109}
1110
1111/// `October 2026`.
1112pub fn month_label(key: &str) -> String {
1113    let (y, m) = parse_month(key);
1114    format!("{} {y}", RECORDS_MONTHS[(m - 1) as usize])
1115}
1116
1117/// One day of the month view.
1118#[derive(Debug, Clone, PartialEq)]
1119pub struct RecordsDay {
1120    pub date: String,
1121    pub day: i64,
1122    pub weekday: &'static str,
1123    pub count: u64,
1124    pub sum: f64,
1125}
1126
1127/// Every day of `key` (`YYYY-MM`), empty.
1128pub fn month_days(key: &str) -> Vec<RecordsDay> {
1129    let (y, m) = parse_month(key);
1130    (1..=days_in_month(y, m)).map(|day| RecordsDay { date: format!("{key}-{day:02}"), day, weekday: RECORDS_WEEKDAYS[weekday_of(y, m, day)], count: 0, sum: 0.0 }).collect()
1131}
1132
1133/// JS `Math.round(n * 1e6) / 1e6`: sums like 0.1 + 0.2 stay readable (and identical ×4).
1134fn round(n: f64) -> f64 {
1135    (n * 1_000_000.0 + 0.5).floor() / 1_000_000.0
1136}
1137
1138/// `s.slice(0, n)` (dates and ISO timestamps: ASCII).
1139fn prefix(s: &str, n: usize) -> String {
1140    s.chars().take(n).collect()
1141}
1142
1143/// One aggregate over rows in the query's order.
1144fn aggregate_of(agg: &RecordsAggregate, rows: &[&RecordRowRef]) -> Value {
1145    let values: Vec<Field> = match &agg.field {
1146        Some(f) => rows.iter().map(|r| field(r, f)).collect(),
1147        None => Vec::new(),
1148    };
1149    match agg.op {
1150        RecordsAggregateOp::Count => json!(if agg.field.is_none() { rows.len() } else { values.iter().filter(|v| !text(v).is_empty()).count() }),
1151        RecordsAggregateOp::Sum => number(round(values.iter().fold(0.0, |acc, v| acc + num(v).unwrap_or(0.0)))),
1152        RecordsAggregateOp::Avg => {
1153            let nums: Vec<f64> = values.iter().filter_map(num).collect();
1154            if nums.is_empty() {
1155                Value::Null
1156            } else {
1157                number(round(nums.iter().fold(0.0, |a, b| a + b) / nums.len() as f64))
1158            }
1159        }
1160        RecordsAggregateOp::Min | RecordsAggregateOp::Max => {
1161            let mut best: Option<&Field> = None;
1162            for v in &values {
1163                if text(v).is_empty() {
1164                    continue;
1165                }
1166                let better = match best {
1167                    None => true,
1168                    Some(b) if agg.op == RecordsAggregateOp::Min => compare(v, b).is_lt(),
1169                    Some(b) => compare(v, b).is_gt(),
1170                };
1171                if better {
1172                    best = Some(v);
1173                }
1174            }
1175            best.map_or(Value::Null, |b| num(b).map_or_else(|| Value::String(text(b).into_owned()), number))
1176        }
1177        RecordsAggregateOp::CountDistinct => {
1178            let mut seen: Vec<Cow<str>> = values.iter().map(text).filter(|t| !t.is_empty()).collect();
1179            seen.sort();
1180            seen.dedup();
1181            json!(seen.len())
1182        }
1183        RecordsAggregateOp::First => values.first().map_or(Value::Null, json_of),
1184        RecordsAggregateOp::Last => values.last().map_or(Value::Null, json_of),
1185    }
1186}
1187
1188fn sum_of(row: &RecordRowRef, f: &str) -> f64 {
1189    num_value(row.data.as_object().and_then(|o| o.get(f))).unwrap_or(0.0)
1190}
1191
1192/// count, sum?, then every aggregate: a group's, `totals`' and `before`'s columns.
1193fn summary(query: &RecordsQuery, rows: &[&RecordRowRef], out: &mut Map<String, Value>) {
1194    out.insert("count".into(), json!(rows.len()));
1195    if let Some(sum) = &query.sum {
1196        out.insert("sum".into(), number(round(rows.iter().fold(0.0, |acc, row| acc + sum_of(row, sum)))));
1197    }
1198    for agg in &query.aggregate {
1199        out.insert(agg.name.clone(), aggregate_of(agg, rows));
1200    }
1201}
1202
1203#[derive(Default, Clone, Copy)]
1204struct RunningAcc {
1205    sum: f64,
1206    count: u64,
1207    nums: u64,
1208    min: Option<f64>,
1209    max: Option<f64>,
1210}
1211
1212/// The running values: fed row by row, read after each step.
1213struct Running<'q> {
1214    list: &'q [RecordsRunning],
1215    acc: Vec<RunningAcc>,
1216}
1217
1218impl<'q> Running<'q> {
1219    fn new(list: &'q [RecordsRunning]) -> Self {
1220        Running { list, acc: vec![RunningAcc::default(); list.len()] }
1221    }
1222
1223    fn add(&mut self, row: &RecordRowRef) {
1224        for (r, a) in self.list.iter().zip(self.acc.iter_mut()) {
1225            a.count += 1;
1226            let Some(n) = num(&field(row, &r.field)) else { continue };
1227            a.sum += n;
1228            a.nums += 1;
1229            a.min = Some(a.min.map_or(n, |m| if n < m { n } else { m }));
1230            a.max = Some(a.max.map_or(n, |m| if n > m { n } else { m }));
1231        }
1232    }
1233
1234    fn read(&self) -> Value {
1235        let mut out = Map::new();
1236        for (r, a) in self.list.iter().zip(&self.acc) {
1237            let v = match r.op {
1238                RecordsRunningOp::Sum => number(round(a.sum)),
1239                RecordsRunningOp::Count => json!(a.count),
1240                RecordsRunningOp::Avg if a.nums > 0 => number(round(a.sum / a.nums as f64)),
1241                RecordsRunningOp::Avg => Value::Null,
1242                RecordsRunningOp::Min => a.min.map_or(Value::Null, number),
1243                RecordsRunningOp::Max => a.max.map_or(Value::Null, number),
1244            };
1245            out.insert(r.field.clone(), v);
1246        }
1247        Value::Object(out)
1248    }
1249}
1250
1251/// A group's part of the key: the author's id, a date's bucket key, any
1252/// other field's text. VAPP-202: computed once per row, borrowed when it can be.
1253fn group_part<'a>(key: &RecordsGroupKey, row: &RecordRowRef<'a>) -> Cow<'a, str> {
1254    if key.field == "author" {
1255        return Cow::Borrowed(row.author().unwrap_or(""));
1256    }
1257    let v = field(row, &key.field);
1258    match key.bucket {
1259        Some(bucket) => Cow::Owned(date_bucket(&text(&v), bucket).unwrap_or_default()),
1260        None => text(&v),
1261    }
1262}
1263
1264/// A group's column (from its first row): `author` = the author's name (+
1265/// `authorId`), a bucket = the date's bucket key, any other field its value.
1266fn group_columns(key: &RecordsGroupKey, row: &RecordRowRef, names: &HashMap<String, String>, cols: &mut Map<String, Value>) {
1267    if key.field == "author" {
1268        let author = row.author();
1269        cols.insert("author".into(), json!(author.and_then(|a| names.get(a)).cloned().unwrap_or_default()));
1270        cols.insert("authorId".into(), json!(author));
1271        return;
1272    }
1273    let v = field(row, &key.field);
1274    if let Some(bucket) = key.bucket {
1275        cols.insert(key.field.clone(), json!(date_bucket(&text(&v), bucket)));
1276        return;
1277    }
1278    cols.insert(key.field.clone(), json_of(&v));
1279}
1280
1281/// `s.slice(0, n)` borrowed (n chars).
1282fn prefix_str(s: &str, n: usize) -> &str {
1283    s.char_indices().nth(n).map_or(s, |(i, _)| &s[..i])
1284}
1285
1286/// One sort column, read once per row: every value a number, every value
1287/// text (ranked: the distinct texts sorted once, each row their index), or
1288/// both (the generic comparison).
1289enum Col<'a> {
1290    Num(Vec<f64>),
1291    Rank(Vec<u32>),
1292    Mixed(Vec<Key<'a>>),
1293}
1294
1295impl<'a> Col<'a> {
1296    fn of(rows: &[&RecordRowRef<'a>], name: &str) -> Self {
1297        let keys: Vec<Key<'a>> = rows.iter().map(|row| Key::of(field(row, name))).collect();
1298        if keys.iter().all(|k| matches!(k, Key::Num(..))) {
1299            return Col::Num(keys.iter().map(|k| if let Key::Num(n, _) = k { *n } else { 0.0 }).collect());
1300        }
1301        if !keys.iter().all(|k| matches!(k, Key::Text(_))) {
1302            return Col::Mixed(keys);
1303        }
1304        let mut ranks: FxHashMap<&str, u32> = FxHashMap::default();
1305        for k in &keys {
1306            ranks.entry(k.text_ref()).or_insert(0);
1307        }
1308        let mut distinct: Vec<&str> = ranks.keys().copied().collect();
1309        distinct.sort_unstable_by(|a, b| utf16_cmp(a, b));
1310        for (i, t) in distinct.iter().enumerate() {
1311            ranks.insert(t, i as u32);
1312        }
1313        Col::Rank(keys.iter().map(|k| ranks[k.text_ref()]).collect())
1314    }
1315
1316    fn cmp(&self, a: usize, b: usize) -> Ordering {
1317        match self {
1318            Col::Num(v) => v[a].partial_cmp(&v[b]).unwrap_or(Ordering::Equal),
1319            Col::Rank(v) => v[a].cmp(&v[b]),
1320            Col::Mixed(v) => cmp_keys(&v[a], &v[b]),
1321        }
1322    }
1323}
1324
1325/// VAPP-202: `rows` in the query's order (sort keys, ties by id), each
1326/// row's keys read ONCE into columns instead of parsing and stringifying
1327/// both sides of every comparison. Without a mixed column the order is
1328/// total (the index breaks an id tie), so the unstable sort gives the
1329/// stable one's result.
1330fn sorted<'r, 'a>(rows: Vec<&'r RecordRowRef<'a>>, sort: &[RecordsSortKey]) -> Vec<&'r RecordRowRef<'a>> {
1331    let cols: Vec<(Col<'a>, bool)> = sort.iter().map(|s| (Col::of(&rows, &s.field), s.order == RecordsOrder::Asc)).collect();
1332    let ascii: Vec<bool> = rows.iter().map(|row| row.id.is_ascii()).collect();
1333    let order = |a: usize, b: usize| {
1334        for (col, asc) in &cols {
1335            let c = col.cmp(a, b);
1336            if c.is_ne() {
1337                return if *asc { c } else { c.reverse() };
1338            }
1339        }
1340        let (ia, ib) = (rows[a].id, rows[b].id);
1341        if ascii[a] || ascii[b] {
1342            ia.as_bytes().cmp(ib.as_bytes())
1343        } else {
1344            utf16_cmp(ia, ib)
1345        }
1346    };
1347    let mut idx: Vec<usize> = (0..rows.len()).collect();
1348    if cols.iter().any(|(c, _)| matches!(c, Col::Mixed(_))) {
1349        idx.sort_by(|&a, &b| order(a, b));
1350    } else {
1351        idx.sort_unstable_by(|&a, &b| order(a, b).then(a.cmp(&b)));
1352    }
1353    idx.into_iter().map(|i| rows[i]).collect()
1354}
1355
1356/// The value a records binding emits for `query` over `rows` (every row of
1357/// the app, any kind): `None` without a kind; else {rows, count, sum?,
1358/// month?, groups?, totals?, before?}: the matches sorted (ties by id) then
1359/// cut at the limit, `count` and `sum` before the limit.
1360pub fn records_value(rows: &[RecordRowRef], query: &RecordsQuery, ctx: RecordsContext) -> Option<Value> {
1361    records_value_in(rows, rows, query, ctx)
1362}
1363
1364/// [`records_value`] with `all` = the rows refs resolve against (before the
1365/// computed fields are written).
1366fn records_value_in(all: &[RecordRowRef], rows: &[RecordRowRef], query: &RecordsQuery, ctx: RecordsContext) -> Option<Value> {
1367    let kind = query.from.as_deref()?;
1368    // VAPP-214: the computed fields ride in each row's data from here on.
1369    if let Some(computed) = ctx.computed.filter(|c| !c.is_empty()) {
1370        let data = computed_data(rows, kind, computed, ctx.now);
1371        let rows: Vec<RecordRowRef> = rows.iter().zip(&data).map(|(row, d)| RecordRowRef { data: d.as_ref().unwrap_or(row.data), ..*row }).collect();
1372        return records_value_in(all, &rows, query, RecordsContext { computed: None, ..ctx });
1373    }
1374    // eApps R5: the expanded fields that are refs of the kind (another name stays as stored).
1375    let expand: Vec<(&str, &Value)> = query.expand.iter().filter_map(|name| ctx.refs.and_then(|r| r.get(name)).map(|t| (name.as_str(), t))).collect();
1376    let by_id: HashMap<&str, &RecordRowRef> = if expand.is_empty() { HashMap::new() } else { all.iter().map(|r| (r.id, r)).collect() };
1377    let view = query.month.as_ref();
1378    let today: &str = ctx.today.filter(|t| is_day_text(t)).unwrap_or_else(|| prefix_str(ctx.now, 10));
1379    let month_key: Option<String> = view.map(|v| v.key.clone().unwrap_or_else(|| prefix(today, 7)));
1380    let where_: Vec<Cond> = query.where_.iter().map(Cond::new).collect();
1381    let or: Vec<Vec<Cond>> = query.or.iter().map(|alt| alt.iter().map(Cond::new).collect()).collect();
1382    let before_date = query.before.as_ref().map(|b| Key::of(Field::Text(&b.date)));
1383    let mut before: Vec<&RecordRowRef> = Vec::new();
1384    let mut matched: Vec<&RecordRowRef> = Vec::new();
1385    for row in rows {
1386        if row.kind != kind {
1387            continue;
1388        }
1389        let author = row.author();
1390        if query.mine && (ctx.me.is_none() || author != ctx.me) {
1391            continue;
1392        }
1393        if let Some(user) = &query.user {
1394            if author != Some(user.as_str()) {
1395                continue;
1396            }
1397        }
1398        if !where_.iter().all(|c| c.holds(row)) {
1399            continue;
1400        }
1401        if !or.is_empty() && !or.iter().any(|alt| alt.iter().all(|c| c.holds(row))) {
1402            continue;
1403        }
1404        if let (Some(b), Some(date)) = (&query.before, &before_date) {
1405            let t = text(&field(row, &b.field));
1406            if !t.is_empty() && cmp_keys(&Key::of(Field::Text(&t)), date).is_lt() {
1407                before.push(row);
1408                continue;
1409            }
1410        }
1411        if let (Some(view), Some(key)) = (view, &month_key) {
1412            if !text(&data_field(row, &view.field)).starts_with(key.as_str()) {
1413                continue;
1414            }
1415        }
1416        matched.push(row);
1417    }
1418    let matched = sorted(matched, &query.sort);
1419    let before = sorted(before, &query.sort);
1420    let seeded = || {
1421        let mut r = Running::new(&query.running);
1422        for row in &before {
1423            r.add(row);
1424        }
1425        r
1426    };
1427    let mut row_running = (!query.running.is_empty() && query.group.is_empty()).then(seeded);
1428    let out_rows: Vec<Value> = matched
1429        .iter()
1430        .take(query.limit as usize)
1431        .map(|row| {
1432            let mut obj = value_row(row, ctx.names);
1433            for (name, target) in &expand {
1434                obj.insert((*name).to_string(), expanded_ref(target, row.data.get(*name), &by_id, &ctx));
1435            }
1436            if let Some(r) = row_running.as_mut() {
1437                r.add(row);
1438                obj.insert("running".into(), r.read());
1439            }
1440            Value::Object(obj)
1441        })
1442        .collect();
1443    let mut out = Map::new();
1444    out.insert("rows".into(), Value::Array(out_rows));
1445    out.insert("count".into(), json!(matched.len()));
1446    if let Some(sum) = &query.sum {
1447        let total = matched.iter().fold(0.0, |acc, row| acc + sum_of(row, sum));
1448        out.insert("sum".into(), number(round(total)));
1449    }
1450    if let (Some(view), Some(key)) = (view, &month_key) {
1451        let mut days = month_days(key);
1452        let mut per_day: Vec<Vec<&RecordRowRef>> = vec![Vec::new(); days.len()];
1453        for row in &matched {
1454            let t = text(&data_field(row, &view.field));
1455            let date = prefix_str(&t, 10);
1456            let Some(i) = days.iter().position(|d| d.date == date) else {
1457                continue;
1458            };
1459            let day = &mut days[i];
1460            day.count += 1;
1461            if let Some(sum) = &query.sum {
1462                day.sum = round(day.sum + sum_of(row, sum));
1463            }
1464            per_day[i].push(row);
1465        }
1466        let mut runnings: Vec<Option<Value>> = vec![None; days.len()];
1467        if !query.running.is_empty() {
1468            let mut r = seeded();
1469            for (i, rows) in per_day.iter().enumerate() {
1470                for row in rows {
1471                    r.add(row);
1472                }
1473                runnings[i] = Some(r.read());
1474            }
1475        }
1476        let labels: Vec<String> = days.iter().map(|d| d.day.to_string()).collect();
1477        let counts: Vec<u64> = days.iter().map(|d| d.count).collect();
1478        let sums: Vec<Value> = days.iter().map(|d| number(d.sum)).collect();
1479        let days_json: Vec<Value> = days
1480            .iter()
1481            .zip(runnings)
1482            .map(|(d, running)| {
1483                let mut day = json!({"date": d.date, "day": d.day, "weekday": d.weekday, "count": d.count, "sum": number(d.sum)});
1484                if let (Some(running), Some(obj)) = (running, day.as_object_mut()) {
1485                    obj.insert("running".into(), running);
1486                }
1487                day
1488            })
1489            .collect();
1490        out.insert(
1491            "month".into(),
1492            json!({
1493                "key": key,
1494                "label": month_label(key),
1495                "prev": shift_month(key, -1),
1496                "next": shift_month(key, 1),
1497                "today": today,
1498                "days": days_json,
1499                "labels": labels,
1500                "counts": counts,
1501                "sums": sums,
1502            }),
1503        );
1504    }
1505    if !query.group.is_empty() {
1506        struct Group<'r, 'a> {
1507            parts: Vec<Cow<'a, str>>,
1508            rows: Vec<&'r RecordRowRef<'a>>,
1509            out: Map<String, Value>,
1510        }
1511        let mut groups: Vec<Group> = Vec::new();
1512        let mut index: FxHashMap<Vec<Cow<str>>, usize> = FxHashMap::default();
1513        let mut parts: Vec<Cow<str>> = Vec::with_capacity(query.group.len());
1514        for row in &matched {
1515            parts.clear();
1516            parts.extend(query.group.iter().map(|key| group_part(key, row)));
1517            match index.get(parts.as_slice()) {
1518                Some(&i) => groups[i].rows.push(row),
1519                None => {
1520                    let mut cols = Map::new();
1521                    for key in &query.group {
1522                        group_columns(key, row, ctx.names, &mut cols);
1523                    }
1524                    index.insert(parts.clone(), groups.len());
1525                    groups.push(Group { parts: parts.clone(), rows: vec![row], out: cols });
1526                }
1527            }
1528        }
1529        for g in &mut groups {
1530            summary(query, &g.rows, &mut g.out);
1531        }
1532        let mut columns: Vec<&str> = Vec::new();
1533        for g in &query.group {
1534            if g.field == "author" {
1535                columns.extend(["author", "authorId"]);
1536            } else {
1537                columns.push(&g.field);
1538            }
1539        }
1540        columns.push("count");
1541        if query.sum.is_some() {
1542            columns.push("sum");
1543        }
1544        columns.extend(query.aggregate.iter().map(|a| a.name.as_str()));
1545        let sort_keys: Vec<&RecordsSortKey> = query.sort.iter().filter(|k| columns.contains(&k.field.as_str())).collect();
1546        let col = |g: &Group, name: &str| -> Value { g.out.get(name).cloned().unwrap_or(Value::Null) };
1547        groups.sort_by(|a, b| {
1548            for key in &sort_keys {
1549                let (va, vb) = (col(a, &key.field), col(b, &key.field));
1550                let c = compare(&Field::Json(&va), &Field::Json(&vb));
1551                if c.is_ne() {
1552                    return if key.order == RecordsOrder::Asc { c } else { c.reverse() };
1553                }
1554            }
1555            for key in &query.group {
1556                let (va, vb) = (col(a, &key.field), col(b, &key.field));
1557                let c = compare(&Field::Json(&va), &Field::Json(&vb));
1558                if c.is_ne() {
1559                    return c;
1560                }
1561            }
1562            for (pa, pb) in a.parts.iter().zip(&b.parts) {
1563                let c = utf16_cmp(pa, pb);
1564                if c.is_ne() {
1565                    return c;
1566                }
1567            }
1568            Ordering::Equal
1569        });
1570        if !query.running.is_empty() {
1571            let mut r = seeded();
1572            for g in &mut groups {
1573                for row in &g.rows {
1574                    r.add(row);
1575                }
1576                g.out.insert("running".into(), r.read());
1577            }
1578        }
1579        out.insert("groups".into(), Value::Array(groups.into_iter().map(|g| Value::Object(g.out)).collect()));
1580    }
1581    if !query.aggregate.is_empty() {
1582        let mut totals = Map::new();
1583        summary(query, &matched, &mut totals);
1584        out.insert("totals".into(), Value::Object(totals));
1585    }
1586    if query.before.is_some() {
1587        let mut b = Map::new();
1588        summary(query, &before, &mut b);
1589        out.insert("before".into(), Value::Object(b));
1590    }
1591    Some(Value::Object(out))
1592}
1593
1594/// [`records_value`] over owned rows (the FFI's).
1595pub fn records_value_owned(rows: &[RecordRow], query: &RecordsQuery, ctx: RecordsContext) -> Option<Value> {
1596    let refs: Vec<RecordRowRef> = rows.iter().map(RecordRow::as_ref).collect();
1597    records_value(&refs, query, ctx)
1598}
1599
1600#[cfg(test)]
1601mod tests {
1602    use super::*;
1603
1604    #[test]
1605    fn number_parses_like_js() {
1606        assert_eq!(js_string_number(" 12 "), 12.0);
1607        assert_eq!(js_string_number("0x10"), 16.0);
1608        assert_eq!(js_string_number("1e2"), 100.0);
1609        assert_eq!(js_string_number(""), 0.0);
1610        assert!(js_string_number("12px").is_nan());
1611        assert!(js_string_number("inf").is_nan());
1612        assert!(js_string_number("-0x1").is_nan());
1613        assert_eq!(js_string_number("-Infinity"), f64::NEG_INFINITY);
1614    }
1615
1616    #[test]
1617    fn months_shift_across_years() {
1618        assert_eq!(shift_month("2026-01", -1), "2025-12");
1619        assert_eq!(shift_month("2026-12", 1), "2027-01");
1620        assert_eq!(shift_month("2026-03", -15), "2024-12");
1621        assert_eq!(month_label("2026-10"), "October 2026");
1622        assert_eq!(month_days("2028-02").len(), 29);
1623    }
1624}
1625
1626/// `YYYY-MM-DD` digits and dashes (the context's `today`; TS `/^\d{4}-\d{2}-\d{2}$/`).
1627fn is_day_text(t: &str) -> bool {
1628    let b = t.as_bytes();
1629    b.len() == 10 && b.iter().enumerate().all(|(i, c)| if i == 4 || i == 7 { *c == b'-' } else { c.is_ascii_digit() })
1630}