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::cmp::Ordering;
16use std::collections::{BTreeMap, HashMap};
17
18use serde::{Deserialize, Serialize};
19use serde_json::{json, Map, Value};
20
21use super::signature::canonical_json;
22use super::sources::{parse_source, source_with_params, ParsedSource};
23use super::{js_trim, utf16_cmp};
24use crate::dates::{date_bucket, days_in_month, weekday_of, DateBucket, DATE_BUCKETS};
25use crate::json::{number, number_to_string};
26
27/// The binding scheme: `records:<kind>[?query]`.
28pub const RECORDS_SCHEME: &str = "records";
29/// The URL sugar's defaults and caps (catalog/host.json `package.records.query`).
30pub const RECORDS_SORT_DEFAULT: &str = "createdAt";
31pub const RECORDS_ORDER_DEFAULT: &str = "desc";
32pub const RECORDS_LIMIT_DEFAULT: u32 = 100;
33pub const RECORDS_LIMIT_MAX: u32 = 500;
34/// Query keys with a meaning of their own; never a field filter.
35pub const RECORDS_RESERVED_PARAMS: &[&str] = &["app", "before", "by", "limit", "mine", "month", "order", "q", "query", "sort", "sum", "user"];
36/// The keys every value row carries next to its data (a data key never shadows one).
37pub const RECORDS_ROW_KEYS: &[&str] = &["id", "createdAt", "updatedAt", "createdBy", "authorId", "author"];
38/// The filter operators: `eq` is the bare key, the others a `.<op>` suffix
39/// (`in` and `between` take comma-separated values).
40pub const RECORDS_FILTER_OPS: &[&str] = &["eq", "gt", "gte", "lt", "lte", "in", "between"];
41/// The JSON form's spellings of the operators.
42pub const RECORDS_OP_ALIASES: &[(&str, &str)] = &[("=", "eq"), ("==", "eq"), (">", "gt"), (">=", "gte"), ("<", "lt"), ("<=", "lte")];
43pub const RECORDS_AGGREGATE_OPS: &[&str] = &["sum", "avg", "min", "max", "count", "countDistinct", "first", "last"];
44pub const RECORDS_RUNNING_OPS: &[&str] = &["sum", "count", "avg", "min", "max"];
45/// Names an aggregate may not take (a group's and a total's own keys).
46pub const RECORDS_AGGREGATE_RESERVED: &[&str] = &["count", "sum", "running", "rows"];
47/// The month view's names (en-US, the same ×4).
48pub const RECORDS_WEEKDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
49pub const RECORDS_MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum RecordsFilterOp {
54    Eq,
55    Gt,
56    Gte,
57    Lt,
58    Lte,
59    In,
60    Between,
61}
62
63impl RecordsFilterOp {
64    pub fn as_str(self) -> &'static str {
65        match self {
66            Self::Eq => "eq",
67            Self::Gt => "gt",
68            Self::Gte => "gte",
69            Self::Lt => "lt",
70            Self::Lte => "lte",
71            Self::In => "in",
72            Self::Between => "between",
73        }
74    }
75
76    /// An operator by name (`eq` … `between`) or its JSON spelling (`<`, `>=`, `==`…).
77    pub fn parse(text: &str) -> Option<Self> {
78        let name = RECORDS_OP_ALIASES.iter().find(|(alias, _)| *alias == text).map_or(text, |(_, op)| op);
79        match name {
80            "eq" => Some(Self::Eq),
81            "gt" => Some(Self::Gt),
82            "gte" => Some(Self::Gte),
83            "lt" => Some(Self::Lt),
84            "lte" => Some(Self::Lte),
85            "in" => Some(Self::In),
86            "between" => Some(Self::Between),
87            _ => None,
88        }
89    }
90
91    /// The suffix operators (`<field>.<op>`); `eq` is the bare key.
92    fn suffix(text: &str) -> Option<Self> {
93        match text {
94            "gt" => Some(Self::Gt),
95            "gte" => Some(Self::Gte),
96            "lt" => Some(Self::Lt),
97            "lte" => Some(Self::Lte),
98            "in" => Some(Self::In),
99            "between" => Some(Self::Between),
100            _ => None,
101        }
102    }
103
104    fn is_list(self) -> bool {
105        matches!(self, Self::In | Self::Between)
106    }
107}
108
109/// A condition's value: one text, or a list (`in`; `between` = [low, high]).
110#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
111#[serde(untagged)]
112pub enum RecordsFilterValue {
113    One(String),
114    Many(Vec<String>),
115}
116
117/// One condition: `eq` / `in` compare as text, the ranges (`between`
118/// inclusive) as numbers when both sides parse, else as text.
119#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
120pub struct RecordsFilter {
121    pub field: String,
122    pub op: RecordsFilterOp,
123    pub value: RecordsFilterValue,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
127#[serde(rename_all = "lowercase")]
128pub enum RecordsOrder {
129    Asc,
130    Desc,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
134pub struct RecordsSortKey {
135    pub field: String,
136    pub order: RecordsOrder,
137}
138
139/// `by=<dateField>&month=YYYY-MM`: the rows of one month, every day listed.
140#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
141pub struct RecordsMonthView {
142    pub field: String,
143    /// `YYYY-MM`; `None` = the month of `now`.
144    pub key: Option<String>,
145}
146
147/// One group-by key: a field (`author` = the author, by name), or a date
148/// field cut into buckets. The group's column is named `field`.
149#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
150pub struct RecordsGroupKey {
151    pub field: String,
152    pub bucket: Option<DateBucket>,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub enum RecordsAggregateOp {
158    Sum,
159    Avg,
160    Min,
161    Max,
162    Count,
163    CountDistinct,
164    First,
165    Last,
166}
167
168impl RecordsAggregateOp {
169    pub fn parse(text: &str) -> Option<Self> {
170        match text {
171            "sum" => Some(Self::Sum),
172            "avg" => Some(Self::Avg),
173            "min" => Some(Self::Min),
174            "max" => Some(Self::Max),
175            "count" => Some(Self::Count),
176            "countDistinct" => Some(Self::CountDistinct),
177            "first" => Some(Self::First),
178            "last" => Some(Self::Last),
179            _ => None,
180        }
181    }
182}
183
184/// `name: [op, field?]`: a column of every group (and of `totals`).
185#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
186pub struct RecordsAggregate {
187    pub name: String,
188    pub op: RecordsAggregateOp,
189    /// `None` = `count` of the rows.
190    pub field: Option<String>,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
194#[serde(rename_all = "lowercase")]
195pub enum RecordsRunningOp {
196    Sum,
197    Count,
198    Avg,
199    Min,
200    Max,
201}
202
203impl RecordsRunningOp {
204    pub fn parse(text: &str) -> Option<Self> {
205        match text {
206            "sum" => Some(Self::Sum),
207            "count" => Some(Self::Count),
208            "avg" => Some(Self::Avg),
209            "min" => Some(Self::Min),
210            "max" => Some(Self::Max),
211            _ => None,
212        }
213    }
214}
215
216/// A cumulative value of `field` over the groups (or rows, or month days) in order.
217#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
218pub struct RecordsRunning {
219    pub field: String,
220    pub op: RecordsRunningOp,
221}
222
223/// The rows whose `field` is before `date` are left out of the rows and
224/// groups, totalled into `before` and seed every running value.
225#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
226pub struct RecordsBefore {
227    pub field: String,
228    pub date: String,
229}
230
231/// The structured query.
232#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
233pub struct RecordsQuery {
234    /// The kind; `None` = no kind (the value is null).
235    pub from: Option<String>,
236    /// Another app's slug (the HOST resolves whose rows); `None` = the hosting app.
237    pub app: Option<String>,
238    /// Only the viewer's rows (by author: `authorId`, else `createdBy`).
239    pub mine: bool,
240    /// Only this user's rows (by author).
241    pub user: Option<String>,
242    /// Every condition holds; by field, then operator (the URL sugar).
243    #[serde(rename = "where")]
244    pub where_: Vec<RecordsFilter>,
245    /// VAPP-190: one of these alternatives holds too (each: every condition).
246    #[serde(default)]
247    pub or: Vec<Vec<RecordsFilter>>,
248    /// Sort keys in order; ties always fall back to the id.
249    pub sort: Vec<RecordsSortKey>,
250    pub limit: u32,
251    /// Σ of this field over the matches (before the limit).
252    pub sum: Option<String>,
253    pub month: Option<RecordsMonthView>,
254    /// VAPP-190: the group-by keys.
255    #[serde(default)]
256    pub group: Vec<RecordsGroupKey>,
257    /// VAPP-190: the aggregates, by name.
258    #[serde(default)]
259    pub aggregate: Vec<RecordsAggregate>,
260    /// VAPP-190: the running values, by field.
261    #[serde(default)]
262    pub running: Vec<RecordsRunning>,
263    #[serde(default)]
264    pub before: Option<RecordsBefore>,
265}
266
267impl Default for RecordsQuery {
268    fn default() -> Self {
269        RecordsQuery {
270            from: None,
271            app: None,
272            mine: false,
273            user: None,
274            where_: Vec::new(),
275            or: Vec::new(),
276            sort: vec![RecordsSortKey { field: RECORDS_SORT_DEFAULT.to_string(), order: RecordsOrder::Desc }],
277            limit: RECORDS_LIMIT_DEFAULT,
278            sum: None,
279            month: None,
280            group: Vec::new(),
281            aggregate: Vec::new(),
282            running: Vec::new(),
283            before: None,
284        }
285    }
286}
287
288/// A problem with a JSON query, a JSON pointer into it.
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct RecordsQueryIssue {
291    pub path: String,
292    pub message: String,
293}
294
295/// JavaScript's `Number(string)`: trimmed, empty = 0, `0x` / `0o` / `0b`
296/// integers, `[+-]Infinity`, a decimal literal; anything else NaN.
297pub(crate) fn js_string_number(s: &str) -> f64 {
298    let t = js_trim(s);
299    if t.is_empty() {
300        return 0.0;
301    }
302    for (prefix, radix) in [("0x", 16), ("0X", 16), ("0o", 8), ("0O", 8), ("0b", 2), ("0B", 2)] {
303        if let Some(digits) = t.strip_prefix(prefix) {
304            if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
305                return f64::NAN;
306            }
307            return digits.chars().fold(0.0, |n, c| n * f64::from(radix) + f64::from(c.to_digit(radix).unwrap_or(0)));
308        }
309    }
310    let (sign, body) = match t.as_bytes()[0] {
311        b'-' => (-1.0, &t[1..]),
312        b'+' => (1.0, &t[1..]),
313        _ => (1.0, t),
314    };
315    if body == "Infinity" {
316        return sign * f64::INFINITY;
317    }
318    let b = body.as_bytes();
319    let mut i = 0;
320    let digits = |i: &mut usize| {
321        let start = *i;
322        while b.get(*i).is_some_and(u8::is_ascii_digit) {
323            *i += 1;
324        }
325        *i - start
326    };
327    let int = digits(&mut i);
328    let frac = if b.get(i) == Some(&b'.') {
329        i += 1;
330        digits(&mut i)
331    } else {
332        0
333    };
334    if int == 0 && frac == 0 {
335        return f64::NAN;
336    }
337    if matches!(b.get(i), Some(b'e' | b'E')) {
338        i += 1;
339        if matches!(b.get(i), Some(b'+' | b'-')) {
340            i += 1;
341        }
342        if digits(&mut i) == 0 {
343            return f64::NAN;
344        }
345    }
346    if i != b.len() {
347        return f64::NAN;
348    }
349    body.parse::<f64>().map(|n| sign * n).unwrap_or(f64::NAN)
350}
351
352/// `YYYY-MM` with a month 01–12.
353fn is_month_key(text: &str) -> bool {
354    let b = text.as_bytes();
355    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")
356}
357
358fn trimmed(v: Option<&str>) -> Option<String> {
359    v.map(js_trim).filter(|s| !s.is_empty()).map(str::to_string)
360}
361
362/// Query params (as [`super::parse_source`] gives them) → the query. `kind`
363/// = the source's own name (`records:<kind>`). Unknown keys are field
364/// filters. VAPP-190: `q=<json>` = the
365/// JSON form ([`parse_records_json`]; the other params are ignored).
366pub fn parse_records_query(params: &BTreeMap<String, String>, kind: Option<&str>) -> RecordsQuery {
367    let from = trimmed(kind);
368    if let Some(q) = params.get("q").filter(|q| !js_trim(q).is_empty()) {
369        let json = serde_json::from_str::<Value>(q).ok().filter(Value::is_object).unwrap_or_else(|| json!({}));
370        return parse_records_json(&json, from.as_deref()).0;
371    }
372    let limit_raw = params.get("limit").map_or(f64::NAN, |l| js_string_number(l));
373    let mut keys: Vec<&String> = params.keys().collect();
374    keys.sort_by(|a, b| utf16_cmp(a, b));
375    let mut where_ = Vec::new();
376    for key in keys {
377        if key.is_empty() || RECORDS_RESERVED_PARAMS.contains(&key.as_str()) {
378            continue;
379        }
380        let value = params[key].clone();
381        match key.rfind('.').and_then(|dot| RecordsFilterOp::suffix(&key[dot + 1..]).map(|op| (dot, op))) {
382            Some((dot, op)) if dot > 0 => {
383                let value = if op.is_list() { RecordsFilterValue::Many(value.split(',').map(str::to_string).collect()) } else { RecordsFilterValue::One(value) };
384                where_.push(RecordsFilter { field: key[..dot].to_string(), op, value });
385            }
386            _ => where_.push(RecordsFilter { field: key.clone(), op: RecordsFilterOp::Eq, value: RecordsFilterValue::One(value) }),
387        }
388    }
389    where_.sort_by(|a, b| utf16_cmp(&a.field, &b.field).then_with(|| utf16_cmp(a.op.as_str(), b.op.as_str())));
390    let order = match params.get("order").map(String::as_str) {
391        Some("asc") => RecordsOrder::Asc,
392        Some("desc") => RecordsOrder::Desc,
393        _ if RECORDS_ORDER_DEFAULT == "asc" => RecordsOrder::Asc,
394        _ => RecordsOrder::Desc,
395    };
396    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 };
397    let by = trimmed(params.get("by").map(String::as_str));
398    let before = trimmed(params.get("before").map(String::as_str));
399    RecordsQuery {
400        from,
401        app: trimmed(params.get("app").map(String::as_str)),
402        mine: matches!(params.get("mine").map(String::as_str), Some("1" | "true")),
403        user: trimmed(params.get("user").map(String::as_str)),
404        where_,
405        sort: vec![RecordsSortKey { field: trimmed(params.get("sort").map(String::as_str)).unwrap_or_else(|| RECORDS_SORT_DEFAULT.to_string()), order }],
406        limit,
407        sum: trimmed(params.get("sum").map(String::as_str)),
408        before: before.map(|date| RecordsBefore { field: by.clone().unwrap_or_else(|| RECORDS_SORT_DEFAULT.to_string()), date }),
409        month: by.map(|field| RecordsMonthView { field, key: params.get("month").filter(|m| is_month_key(m)).cloned() }),
410        ..RecordsQuery::default()
411    }
412}
413
414/// `records:<kind>?…` → the query; `None` = not a records source.
415pub fn parse_records_source(source: &ParsedSource) -> Option<RecordsQuery> {
416    (source.scheme == RECORDS_SCHEME).then(|| parse_records_query(&source.params, Some(&source.name)))
417}
418
419/// A JSON scalar as the query's text (a number as JS prints it); `None` = not a scalar.
420fn scalar_text(v: &Value) -> Option<String> {
421    match v {
422        Value::Null => Some(String::new()),
423        Value::String(s) => Some(s.clone()),
424        Value::Bool(b) => Some(b.to_string()),
425        Value::Number(n) => Some(number_to_string(n.as_f64().unwrap_or(0.0))),
426        _ => None,
427    }
428}
429
430fn issue(issues: &mut Vec<RecordsQueryIssue>, path: impl Into<String>, message: impl Into<String>) {
431    issues.push(RecordsQueryIssue { path: path.into(), message: message.into() });
432}
433
434/// `[field, op, value…]` → a condition.
435fn parse_condition(entry: &Value, at: &str, issues: &mut Vec<RecordsQueryIssue>) -> Option<RecordsFilter> {
436    let Some(list) = entry.as_array().filter(|l| l.len() >= 3) else {
437        issue(issues, at, "a condition is [field, op, value]");
438        return None;
439    };
440    let Some(field) = list[0].as_str().map(js_trim).filter(|f| !f.is_empty()) else {
441        issue(issues, format!("{at}/0"), "a condition's field is a non-empty string");
442        return None;
443    };
444    let Some(op) = list[1].as_str().and_then(RecordsFilterOp::parse) else {
445        issue(issues, format!("{at}/1"), format!("unknown operator {}", crate::json::to_js_string(&list[1])));
446        return None;
447    };
448    let rest = &list[2..];
449    if op == RecordsFilterOp::In {
450        let values: Option<Vec<String>> = match rest {
451            [Value::Array(items)] => items.iter().map(scalar_text).collect(),
452            _ => None,
453        };
454        let Some(values) = values else {
455            issue(issues, format!("{at}/2"), "in takes one list of values");
456            return None;
457        };
458        return Some(RecordsFilter { field: field.to_string(), op, value: RecordsFilterValue::Many(values) });
459    }
460    let values: Option<Vec<String>> = rest.iter().map(scalar_text).collect();
461    let want = if op == RecordsFilterOp::Between { 2 } else { 1 };
462    let Some(mut values) = values.filter(|v| v.len() == want) else {
463        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()) });
464        return None;
465    };
466    let value = if op == RecordsFilterOp::Between { RecordsFilterValue::Many(values) } else { RecordsFilterValue::One(values.remove(0)) };
467    Some(RecordsFilter { field: field.to_string(), op, value })
468}
469
470const QUERY_KEYS: &[&str] = &["from", "app", "mine", "user", "where", "sort", "limit", "sum", "month", "group", "aggregate", "running", "before"];
471
472fn sorted_keys(map: &Map<String, Value>) -> Vec<&String> {
473    let mut keys: Vec<&String> = map.keys().collect();
474    keys.sort_by(|a, b| utf16_cmp(a, b));
475    keys
476}
477
478/// VAPP-190: the JSON form (a package's `queries`, the `q` param) → the
479/// query and every problem with it (a problem drops the part, the rest
480/// still runs). `kind` (a source's own) wins over `from`.
481pub fn parse_records_json(json: &Value, kind: Option<&str>) -> (RecordsQuery, Vec<RecordsQueryIssue>) {
482    let mut issues = Vec::new();
483    let mut query = RecordsQuery::default();
484    let Some(obj) = json.as_object() else {
485        issue(&mut issues, "", "a query is an object");
486        query.from = trimmed(kind);
487        return (query, issues);
488    };
489    for key in sorted_keys(obj) {
490        if !QUERY_KEYS.contains(&key.as_str()) && !key.starts_with('$') {
491            issue(&mut issues, format!("/{key}"), format!("unknown key {key}"));
492        }
493    }
494    let str_of = |key: &str, issues: &mut Vec<RecordsQueryIssue>| -> Option<String> {
495        match obj.get(key) {
496            None | Some(Value::Null) => None,
497            Some(Value::String(s)) => trimmed(Some(s)),
498            Some(_) => {
499                issue(issues, format!("/{key}"), format!("{key} is a string"));
500                None
501            }
502        }
503    };
504    query.from = match trimmed(kind) {
505        Some(k) => Some(k),
506        None => str_of("from", &mut issues),
507    };
508    query.app = str_of("app", &mut issues);
509    query.user = str_of("user", &mut issues);
510    query.sum = str_of("sum", &mut issues);
511    match obj.get("mine") {
512        None => {}
513        Some(Value::Bool(b)) => query.mine = *b,
514        Some(_) => issue(&mut issues, "/mine", "mine is a boolean"),
515    }
516    match obj.get("where") {
517        None => {}
518        Some(Value::Array(list)) => {
519            for (i, entry) in list.iter().enumerate() {
520                let at = format!("/where/{i}");
521                if let Some(alt) = entry.as_object() {
522                    let Some(alternatives) = alt.get("or").and_then(Value::as_array).filter(|_| alt.len() == 1) else {
523                        issue(&mut issues, at, "an alternative is {or: [[condition…]…]}");
524                        continue;
525                    };
526                    if !query.or.is_empty() {
527                        issue(&mut issues, at, "one or per query");
528                        continue;
529                    }
530                    for (j, alt) in alternatives.iter().enumerate() {
531                        let Some(conds) = alt.as_array() else {
532                            issue(&mut issues, format!("{at}/or/{j}"), "an alternative is a list of conditions");
533                            continue;
534                        };
535                        let parsed: Vec<RecordsFilter> = conds.iter().enumerate().filter_map(|(k, c)| parse_condition(c, &format!("{at}/or/{j}/{k}"), &mut issues)).collect();
536                        query.or.push(parsed);
537                    }
538                    continue;
539                }
540                if let Some(parsed) = parse_condition(entry, &at, &mut issues) {
541                    query.where_.push(parsed);
542                }
543            }
544        }
545        Some(_) => issue(&mut issues, "/where", "where is a list of conditions"),
546    }
547    if let Some(sort) = obj.get("sort") {
548        let single = sort.is_string();
549        let list: Option<Vec<Value>> = if single { Some(vec![sort.clone()]) } else { sort.as_array().cloned() };
550        match list {
551            None => issue(&mut issues, "/sort", "sort is a field or a list of [field, order]"),
552            Some(list) => {
553                let mut keys = Vec::new();
554                for (i, entry) in list.iter().enumerate() {
555                    let asc = Value::String("asc".into());
556                    let (name, order): (Option<&Value>, Option<&Value>) = match entry {
557                        Value::String(_) => (Some(entry), Some(&asc)),
558                        Value::Array(pair) => (pair.first(), if pair.len() > 1 { pair.get(1) } else { Some(&asc) }),
559                        _ => (None, None),
560                    };
561                    let name = name.and_then(Value::as_str).map(js_trim).filter(|n| !n.is_empty());
562                    let order = match order.and_then(Value::as_str) {
563                        Some("asc") => Some(RecordsOrder::Asc),
564                        Some("desc") => Some(RecordsOrder::Desc),
565                        _ => None,
566                    };
567                    match (name, order) {
568                        (Some(name), Some(order)) => keys.push(RecordsSortKey { field: name.to_string(), order }),
569                        _ => issue(&mut issues, if single { "/sort".to_string() } else { format!("/sort/{i}") }, "a sort key is field or [field, asc|desc]"),
570                    }
571                }
572                if !keys.is_empty() {
573                    query.sort = keys;
574                }
575            }
576        }
577    }
578    if let Some(limit) = obj.get("limit") {
579        let n = match limit {
580            Value::Number(n) => n.as_f64().unwrap_or(f64::NAN),
581            Value::String(s) if !js_trim(s).is_empty() => js_string_number(s),
582            _ => f64::NAN,
583        };
584        if n.is_finite() && n.fract() == 0.0 && n >= 1.0 {
585            query.limit = n.min(f64::from(RECORDS_LIMIT_MAX)) as u32;
586        } else {
587            issue(&mut issues, "/limit", "limit is a positive integer");
588        }
589    }
590    match obj.get("month") {
591        None | Some(Value::Null) => {}
592        Some(m) => {
593            let field = m.get("field").and_then(Value::as_str).map(js_trim).filter(|f| !f.is_empty());
594            let key = m.get("key");
595            match (m.as_object(), field) {
596                (Some(_), Some(field)) if matches!(key, None | Some(Value::Null) | Some(Value::String(_))) => {
597                    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) });
598                }
599                _ => issue(&mut issues, "/month", "month is {field, key?}"),
600            }
601        }
602    }
603    match obj.get("group") {
604        None => {}
605        Some(Value::Array(list)) => {
606            for (i, entry) in list.iter().enumerate() {
607                let at = format!("/group/{i}");
608                let key = match entry {
609                    Value::String(s) if !js_trim(s).is_empty() => Some(RecordsGroupKey { field: js_trim(s).to_string(), bucket: None }),
610                    Value::Object(o) if o.len() == 2 => {
611                        let field = o.get("bucket").and_then(Value::as_str).map(js_trim).filter(|f| !f.is_empty());
612                        let bucket = o.get("by").and_then(Value::as_str).and_then(DateBucket::parse);
613                        field.zip(bucket).map(|(field, bucket)| RecordsGroupKey { field: field.to_string(), bucket: Some(bucket) })
614                    }
615                    _ => None,
616                };
617                let Some(key) = key else {
618                    issue(&mut issues, at, format!("a group key is a field or {{bucket: field, by: {}}}", DATE_BUCKETS.join("|")));
619                    continue;
620                };
621                if query.group.iter().any(|g| g.field == key.field || (key.field == "author" && g.field == "authorId") || (key.field == "authorId" && g.field == "author")) {
622                    issue(&mut issues, at, format!("duplicate group {}", key.field));
623                    continue;
624                }
625                query.group.push(key);
626            }
627        }
628        Some(_) => issue(&mut issues, "/group", "group is a list of keys"),
629    }
630    match obj.get("aggregate") {
631        None => {}
632        Some(Value::Object(aggs)) => {
633            for name in sorted_keys(aggs) {
634                let def = &aggs[name];
635                let at = format!("/aggregate/{name}");
636                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")) {
637                    issue(&mut issues, at, format!("the name {name} is taken"));
638                    continue;
639                }
640                let list = def.as_array();
641                let op = list.and_then(|l| l.first()).and_then(Value::as_str).and_then(RecordsAggregateOp::parse);
642                let len = list.map_or(0, Vec::len);
643                let field = list.filter(|l| l.len() > 1).and_then(|l| l[1].as_str()).map(js_trim).filter(|f| !f.is_empty());
644                match op {
645                    Some(op) if len <= 2 && !(len > 1 && field.is_none()) && (op == RecordsAggregateOp::Count || field.is_some()) => {
646                        query.aggregate.push(RecordsAggregate { name: name.clone(), op, field: field.map(str::to_string) });
647                    }
648                    _ => issue(&mut issues, at, format!("an aggregate is [{}, field] (count: the field is optional)", RECORDS_AGGREGATE_OPS.join("|"))),
649                }
650            }
651        }
652        Some(_) => issue(&mut issues, "/aggregate", "aggregate is {name: [op, field?]}"),
653    }
654    match obj.get("running") {
655        None => {}
656        Some(Value::Object(running)) => {
657            for name in sorted_keys(running) {
658                match running[name].as_str().and_then(RecordsRunningOp::parse) {
659                    Some(op) if !js_trim(name).is_empty() => query.running.push(RecordsRunning { field: name.clone(), op }),
660                    _ => issue(&mut issues, format!("/running/{name}"), format!("a running value is {{field: {}}}", RECORDS_RUNNING_OPS.join("|"))),
661                }
662            }
663        }
664        Some(_) => issue(&mut issues, "/running", "running is {field: op}"),
665    }
666    match obj.get("before") {
667        None | Some(Value::Null) => {}
668        Some(Value::String(date)) => {
669            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());
670            query.before = trimmed(Some(date)).map(|date| RecordsBefore { field: implied, date });
671        }
672        Some(b) => {
673            let field = b.as_object().and_then(|o| o.get("field")).and_then(Value::as_str).map(js_trim).filter(|f| !f.is_empty());
674            match (field, b.get("date").and_then(Value::as_str)) {
675                (Some(field), Some(date)) => query.before = trimmed(Some(date)).map(|date| RecordsBefore { field: field.to_string(), date }),
676                _ => issue(&mut issues, "/before", "before is a date or {field, date}"),
677            }
678        }
679    }
680    (query, issues)
681}
682
683/// `${name}` in every string of a JSON query → `params[name]` ("" when unset).
684pub fn fill_query_templates(value: &Value, params: &BTreeMap<String, String>) -> Value {
685    match value {
686        Value::String(s) => {
687            let mut out = String::new();
688            let mut rest = s.as_str();
689            while let Some(start) = rest.find("${") {
690                let Some(len) = rest[start + 2..].find('}') else { break };
691                out.push_str(&rest[..start]);
692                let name = js_trim(&rest[start + 2..start + 2 + len]);
693                out.push_str(params.get(name).map_or("", String::as_str));
694                rest = &rest[start + 2 + len + 1..];
695            }
696            out.push_str(rest);
697            Value::String(out)
698        }
699        Value::Array(items) => Value::Array(items.iter().map(|v| fill_query_templates(v, params)).collect()),
700        Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), fill_query_templates(v, params))).collect()),
701        other => other.clone(),
702    }
703}
704
705/// VAPP-190: a package binding `<scheme>:<name>?query=<id>&<params>` naming
706/// one of the package's `queries` → the same source with that query, its
707/// `${param}` templates filled from the other params, as `q=<canonical
708/// JSON>` (a `kind` param stays). Anything else is returned as written.
709pub fn expand_query_source(source: &str, queries: Option<&Value>) -> String {
710    let Some(parsed) = parse_source(source) else { return source.to_string() };
711    let Some(id) = parsed.params.get("query") else { return source.to_string() };
712    let Some(query) = queries.and_then(Value::as_object).and_then(|q| q.get(id)) else { return source.to_string() };
713    let mut params = parsed.params.clone();
714    params.remove("query");
715    let filled = fill_query_templates(query, &params);
716    let base = js_trim(source);
717    let head = base.split_once('?').map_or(base, |(h, _)| h);
718    source_with_params(head, Some(&json!({ "q": canonical_json(&filled) })))
719}
720
721/// One stored record as a host hands it to the evaluator (borrowed).
722#[derive(Debug, Clone, Copy, PartialEq)]
723pub struct RecordRowRef<'a> {
724    pub id: &'a str,
725    pub kind: &'a str,
726    pub data: &'a Value,
727    pub created_by: Option<&'a str>,
728    /// Whose record it is (#VAPP-187); `None` = `created_by`.
729    pub author_id: Option<&'a str>,
730    pub created_at: &'a str,
731    pub updated_at: &'a str,
732}
733
734impl<'a> RecordRowRef<'a> {
735    /// The row's author: `authorId`, else `createdBy`.
736    pub fn author(&self) -> Option<&'a str> {
737        self.author_id.or(self.created_by)
738    }
739}
740
741/// One stored record, owned (the FFI's rows JSON).
742#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
743#[serde(rename_all = "camelCase")]
744pub struct RecordRow {
745    pub id: String,
746    pub kind: String,
747    #[serde(default)]
748    pub data: Value,
749    #[serde(default)]
750    pub created_by: Option<String>,
751    #[serde(default)]
752    pub author_id: Option<String>,
753    pub created_at: String,
754    pub updated_at: String,
755}
756
757impl RecordRow {
758    pub fn as_ref(&self) -> RecordRowRef<'_> {
759        RecordRowRef {
760            id: &self.id,
761            kind: &self.kind,
762            data: &self.data,
763            created_by: self.created_by.as_deref(),
764            author_id: self.author_id.as_deref(),
765            created_at: &self.created_at,
766            updated_at: &self.updated_at,
767        }
768    }
769}
770
771/// What [`records_value`] needs besides the rows.
772#[derive(Debug, Clone, Copy)]
773pub struct RecordsContext<'a> {
774    /// The viewer's user id (`None` = signed out: `mine` matches nothing).
775    pub me: Option<&'a str>,
776    /// ISO 8601 (UTC): the month view's default month and `today`.
777    pub now: &'a str,
778    /// user id → display name (`author`; "" when unknown).
779    pub names: &'a HashMap<String, String>,
780}
781
782/// A field as a JS value (`undefined` = `Missing`).
783enum Field<'a> {
784    Text(&'a str),
785    Json(&'a Value),
786    Missing,
787}
788
789fn num_text(s: &str) -> Option<f64> {
790    if js_trim(s).is_empty() {
791        return None;
792    }
793    Some(js_string_number(s)).filter(|f| f.is_finite())
794}
795
796fn num(v: &Field) -> Option<f64> {
797    match v {
798        Field::Json(Value::Number(n)) => n.as_f64().filter(|f| f.is_finite()),
799        Field::Json(Value::String(s)) => num_text(s),
800        Field::Text(s) => num_text(s),
801        Field::Json(_) | Field::Missing => None,
802    }
803}
804
805fn num_value(v: Option<&Value>) -> Option<f64> {
806    v.and_then(|v| num(&Field::Json(v)))
807}
808
809fn text(v: &Field) -> String {
810    match v {
811        Field::Missing | Field::Json(Value::Null) => String::new(),
812        Field::Text(s) => (*s).to_string(),
813        Field::Json(Value::String(s)) => s.clone(),
814        Field::Json(Value::Bool(b)) => b.to_string(),
815        Field::Json(Value::Number(n)) => number_to_string(n.as_f64().unwrap_or(0.0)),
816        Field::Json(other) => canonical_json(other),
817    }
818}
819
820/// The field as JSON (`undefined` = null).
821fn json_of(v: &Field) -> Value {
822    match v {
823        Field::Missing => Value::Null,
824        Field::Text(s) => Value::String((*s).to_string()),
825        Field::Json(v) => (*v).clone(),
826    }
827}
828
829/// Numbers when both parse, else text (UTF-16 code units, no locale).
830fn compare(a: &Field, b: &Field) -> Ordering {
831    if let (Some(na), Some(nb)) = (num(a), num(b)) {
832        return na.partial_cmp(&nb).unwrap_or(Ordering::Equal);
833    }
834    utf16_cmp(&text(a), &text(b))
835}
836
837fn holds(filter: &RecordsFilter, value: &Field) -> bool {
838    let list: Vec<&str> = match &filter.value {
839        RecordsFilterValue::One(v) => vec![v.as_str()],
840        RecordsFilterValue::Many(vs) => vs.iter().map(String::as_str).collect(),
841    };
842    match filter.op {
843        RecordsFilterOp::Eq => match &filter.value {
844            RecordsFilterValue::One(v) => text(value) == *v,
845            RecordsFilterValue::Many(_) => false,
846        },
847        RecordsFilterOp::In => {
848            let t = text(value);
849            list.iter().any(|v| *v == t)
850        }
851        RecordsFilterOp::Between => list.len() == 2 && compare(value, &Field::Text(list[0])).is_ge() && compare(value, &Field::Text(list[1])).is_le(),
852        op => {
853            let Some(bound) = list.first().filter(|_| list.len() == 1) else { return false };
854            let c = compare(value, &Field::Text(bound));
855            match op {
856                RecordsFilterOp::Gt => c.is_gt(),
857                RecordsFilterOp::Gte => c.is_ge(),
858                RecordsFilterOp::Lt => c.is_lt(),
859                _ => c.is_le(),
860            }
861        }
862    }
863}
864
865fn data_field<'a>(row: &RecordRowRef<'a>, name: &str) -> Field<'a> {
866    match row.data.as_object().and_then(|o| o.get(name)) {
867        Some(v) => Field::Json(v),
868        None => Field::Missing,
869    }
870}
871
872static NULL: Value = Value::Null;
873
874/// A field as the query reads it: a row key (`authorId` = the author), else the data's.
875fn field<'a>(row: &RecordRowRef<'a>, name: &str) -> Field<'a> {
876    match name {
877        "createdAt" => Field::Text(row.created_at),
878        "updatedAt" => Field::Text(row.updated_at),
879        "id" => Field::Text(row.id),
880        "createdBy" => row.created_by.map_or(Field::Json(&NULL), Field::Text),
881        "authorId" => row.author().map_or(Field::Json(&NULL), Field::Text),
882        _ => data_field(row, name),
883    }
884}
885
886fn parse_month(key: &str) -> (i64, i64) {
887    let (y, m) = key.split_once('-').unwrap_or(("1970", "1"));
888    (y.parse().unwrap_or(1970), m.parse::<i64>().unwrap_or(1).clamp(1, 12))
889}
890
891/// `YYYY-MM` ± `by` months.
892pub fn shift_month(key: &str, by: i64) -> String {
893    let (y, m) = parse_month(key);
894    let index = y * 12 + (m - 1) + by;
895    format!("{}-{:02}", index.div_euclid(12), index.rem_euclid(12) + 1)
896}
897
898/// `October 2026`.
899pub fn month_label(key: &str) -> String {
900    let (y, m) = parse_month(key);
901    format!("{} {y}", RECORDS_MONTHS[(m - 1) as usize])
902}
903
904/// One day of the month view.
905#[derive(Debug, Clone, PartialEq)]
906pub struct RecordsDay {
907    pub date: String,
908    pub day: i64,
909    pub weekday: &'static str,
910    pub count: u64,
911    pub sum: f64,
912}
913
914/// Every day of `key` (`YYYY-MM`), empty.
915pub fn month_days(key: &str) -> Vec<RecordsDay> {
916    let (y, m) = parse_month(key);
917    (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()
918}
919
920/// JS `Math.round(n * 1e6) / 1e6`: sums like 0.1 + 0.2 stay readable (and identical ×4).
921fn round(n: f64) -> f64 {
922    (n * 1_000_000.0 + 0.5).floor() / 1_000_000.0
923}
924
925/// `s.slice(0, n)` (dates and ISO timestamps: ASCII).
926fn prefix(s: &str, n: usize) -> String {
927    s.chars().take(n).collect()
928}
929
930/// One aggregate over rows in the query's order.
931fn aggregate_of(agg: &RecordsAggregate, rows: &[&RecordRowRef]) -> Value {
932    let values: Vec<Field> = match &agg.field {
933        Some(f) => rows.iter().map(|r| field(r, f)).collect(),
934        None => Vec::new(),
935    };
936    match agg.op {
937        RecordsAggregateOp::Count => json!(if agg.field.is_none() { rows.len() } else { values.iter().filter(|v| !text(v).is_empty()).count() }),
938        RecordsAggregateOp::Sum => number(round(values.iter().fold(0.0, |acc, v| acc + num(v).unwrap_or(0.0)))),
939        RecordsAggregateOp::Avg => {
940            let nums: Vec<f64> = values.iter().filter_map(num).collect();
941            if nums.is_empty() {
942                Value::Null
943            } else {
944                number(round(nums.iter().fold(0.0, |a, b| a + b) / nums.len() as f64))
945            }
946        }
947        RecordsAggregateOp::Min | RecordsAggregateOp::Max => {
948            let mut best: Option<&Field> = None;
949            for v in &values {
950                if text(v).is_empty() {
951                    continue;
952                }
953                let better = match best {
954                    None => true,
955                    Some(b) if agg.op == RecordsAggregateOp::Min => compare(v, b).is_lt(),
956                    Some(b) => compare(v, b).is_gt(),
957                };
958                if better {
959                    best = Some(v);
960                }
961            }
962            best.map_or(Value::Null, |b| num(b).map_or_else(|| Value::String(text(b)), number))
963        }
964        RecordsAggregateOp::CountDistinct => {
965            let mut seen: Vec<String> = values.iter().map(text).filter(|t| !t.is_empty()).collect();
966            seen.sort();
967            seen.dedup();
968            json!(seen.len())
969        }
970        RecordsAggregateOp::First => values.first().map_or(Value::Null, json_of),
971        RecordsAggregateOp::Last => values.last().map_or(Value::Null, json_of),
972    }
973}
974
975fn sum_of(row: &RecordRowRef, f: &str) -> f64 {
976    num_value(row.data.as_object().and_then(|o| o.get(f))).unwrap_or(0.0)
977}
978
979/// count, sum?, then every aggregate: a group's, `totals`' and `before`'s columns.
980fn summary(query: &RecordsQuery, rows: &[&RecordRowRef], out: &mut Map<String, Value>) {
981    out.insert("count".into(), json!(rows.len()));
982    if let Some(sum) = &query.sum {
983        out.insert("sum".into(), number(round(rows.iter().fold(0.0, |acc, row| acc + sum_of(row, sum)))));
984    }
985    for agg in &query.aggregate {
986        out.insert(agg.name.clone(), aggregate_of(agg, rows));
987    }
988}
989
990#[derive(Default, Clone, Copy)]
991struct RunningAcc {
992    sum: f64,
993    count: u64,
994    nums: u64,
995    min: Option<f64>,
996    max: Option<f64>,
997}
998
999/// The running values: fed row by row, read after each step.
1000struct Running<'q> {
1001    list: &'q [RecordsRunning],
1002    acc: Vec<RunningAcc>,
1003}
1004
1005impl<'q> Running<'q> {
1006    fn new(list: &'q [RecordsRunning]) -> Self {
1007        Running { list, acc: vec![RunningAcc::default(); list.len()] }
1008    }
1009
1010    fn add(&mut self, row: &RecordRowRef) {
1011        for (r, a) in self.list.iter().zip(self.acc.iter_mut()) {
1012            a.count += 1;
1013            let Some(n) = num(&field(row, &r.field)) else { continue };
1014            a.sum += n;
1015            a.nums += 1;
1016            a.min = Some(a.min.map_or(n, |m| if n < m { n } else { m }));
1017            a.max = Some(a.max.map_or(n, |m| if n > m { n } else { m }));
1018        }
1019    }
1020
1021    fn read(&self) -> Value {
1022        let mut out = Map::new();
1023        for (r, a) in self.list.iter().zip(&self.acc) {
1024            let v = match r.op {
1025                RecordsRunningOp::Sum => number(round(a.sum)),
1026                RecordsRunningOp::Count => json!(a.count),
1027                RecordsRunningOp::Avg if a.nums > 0 => number(round(a.sum / a.nums as f64)),
1028                RecordsRunningOp::Avg => Value::Null,
1029                RecordsRunningOp::Min => a.min.map_or(Value::Null, number),
1030                RecordsRunningOp::Max => a.max.map_or(Value::Null, number),
1031            };
1032            out.insert(r.field.clone(), v);
1033        }
1034        Value::Object(out)
1035    }
1036}
1037
1038/// A group's column: `author` = the author's name (+ `authorId`), a bucket =
1039/// the date's bucket key, any other field its value.
1040fn group_columns(key: &RecordsGroupKey, row: &RecordRowRef, names: &HashMap<String, String>, cols: &mut Map<String, Value>) -> String {
1041    if key.field == "author" {
1042        let author = row.author();
1043        cols.insert("author".into(), json!(author.and_then(|a| names.get(a)).cloned().unwrap_or_default()));
1044        cols.insert("authorId".into(), json!(author));
1045        return author.unwrap_or("").to_string();
1046    }
1047    let v = field(row, &key.field);
1048    if let Some(bucket) = key.bucket {
1049        let b = date_bucket(&text(&v), bucket);
1050        cols.insert(key.field.clone(), json!(b));
1051        return b.unwrap_or_default();
1052    }
1053    cols.insert(key.field.clone(), json_of(&v));
1054    text(&v)
1055}
1056
1057/// The value a records binding emits for `query` over `rows` (every row of
1058/// the app, any kind): `None` without a kind; else {rows, count, sum?,
1059/// month?, groups?, totals?, before?}: the matches sorted (ties by id) then
1060/// cut at the limit, `count` and `sum` before the limit.
1061pub fn records_value(rows: &[RecordRowRef], query: &RecordsQuery, ctx: RecordsContext) -> Option<Value> {
1062    let kind = query.from.as_deref()?;
1063    let view = query.month.as_ref();
1064    let month_key: Option<String> = view.map(|v| v.key.clone().unwrap_or_else(|| prefix(ctx.now, 7)));
1065    let mut before: Vec<&RecordRowRef> = Vec::new();
1066    let mut matched: Vec<&RecordRowRef> = Vec::new();
1067    for row in rows {
1068        if row.kind != kind {
1069            continue;
1070        }
1071        let author = row.author();
1072        if query.mine && (ctx.me.is_none() || author != ctx.me) {
1073            continue;
1074        }
1075        if let Some(user) = &query.user {
1076            if author != Some(user.as_str()) {
1077                continue;
1078            }
1079        }
1080        if !query.where_.iter().all(|f| holds(f, &field(row, &f.field))) {
1081            continue;
1082        }
1083        if !query.or.is_empty() && !query.or.iter().any(|alt| alt.iter().all(|f| holds(f, &field(row, &f.field)))) {
1084            continue;
1085        }
1086        if let Some(b) = &query.before {
1087            let t = text(&field(row, &b.field));
1088            if !t.is_empty() && compare(&Field::Text(&t), &Field::Text(&b.date)).is_lt() {
1089                before.push(row);
1090                continue;
1091            }
1092        }
1093        if let (Some(view), Some(key)) = (view, &month_key) {
1094            if !text(&data_field(row, &view.field)).starts_with(key.as_str()) {
1095                continue;
1096            }
1097        }
1098        matched.push(row);
1099    }
1100    let order = |a: &&RecordRowRef, b: &&RecordRowRef| {
1101        for key in &query.sort {
1102            let c = compare(&field(a, &key.field), &field(b, &key.field));
1103            if c.is_ne() {
1104                return if key.order == RecordsOrder::Asc { c } else { c.reverse() };
1105            }
1106        }
1107        utf16_cmp(a.id, b.id)
1108    };
1109    matched.sort_by(order);
1110    before.sort_by(order);
1111    let seeded = || {
1112        let mut r = Running::new(&query.running);
1113        for row in &before {
1114            r.add(row);
1115        }
1116        r
1117    };
1118    let mut row_running = (!query.running.is_empty() && query.group.is_empty()).then(seeded);
1119    let out_rows: Vec<Value> = matched
1120        .iter()
1121        .take(query.limit as usize)
1122        .map(|row| {
1123            let mut obj: Map<String, Value> = row.data.as_object().cloned().unwrap_or_default();
1124            obj.insert("id".into(), json!(row.id));
1125            obj.insert("createdAt".into(), json!(row.created_at));
1126            obj.insert("updatedAt".into(), json!(row.updated_at));
1127            obj.insert("createdBy".into(), json!(row.created_by));
1128            obj.insert("authorId".into(), json!(row.author()));
1129            let author = row.author().and_then(|a| ctx.names.get(a)).cloned().unwrap_or_default();
1130            obj.insert("author".into(), json!(author));
1131            if let Some(r) = row_running.as_mut() {
1132                r.add(row);
1133                obj.insert("running".into(), r.read());
1134            }
1135            Value::Object(obj)
1136        })
1137        .collect();
1138    let mut out = Map::new();
1139    out.insert("rows".into(), Value::Array(out_rows));
1140    out.insert("count".into(), json!(matched.len()));
1141    if let Some(sum) = &query.sum {
1142        let total = matched.iter().fold(0.0, |acc, row| acc + sum_of(row, sum));
1143        out.insert("sum".into(), number(round(total)));
1144    }
1145    if let (Some(view), Some(key)) = (view, &month_key) {
1146        let mut days = month_days(key);
1147        let mut per_day: Vec<Vec<&RecordRowRef>> = vec![Vec::new(); days.len()];
1148        for row in &matched {
1149            let date = prefix(&text(&data_field(row, &view.field)), 10);
1150            let Some(i) = days.iter().position(|d| d.date == date) else {
1151                continue;
1152            };
1153            let day = &mut days[i];
1154            day.count += 1;
1155            if let Some(sum) = &query.sum {
1156                day.sum = round(day.sum + sum_of(row, sum));
1157            }
1158            per_day[i].push(row);
1159        }
1160        let mut runnings: Vec<Option<Value>> = vec![None; days.len()];
1161        if !query.running.is_empty() {
1162            let mut r = seeded();
1163            for (i, rows) in per_day.iter().enumerate() {
1164                for row in rows {
1165                    r.add(row);
1166                }
1167                runnings[i] = Some(r.read());
1168            }
1169        }
1170        let labels: Vec<String> = days.iter().map(|d| d.day.to_string()).collect();
1171        let counts: Vec<u64> = days.iter().map(|d| d.count).collect();
1172        let sums: Vec<Value> = days.iter().map(|d| number(d.sum)).collect();
1173        let days_json: Vec<Value> = days
1174            .iter()
1175            .zip(runnings)
1176            .map(|(d, running)| {
1177                let mut day = json!({"date": d.date, "day": d.day, "weekday": d.weekday, "count": d.count, "sum": number(d.sum)});
1178                if let (Some(running), Some(obj)) = (running, day.as_object_mut()) {
1179                    obj.insert("running".into(), running);
1180                }
1181                day
1182            })
1183            .collect();
1184        out.insert(
1185            "month".into(),
1186            json!({
1187                "key": key,
1188                "label": month_label(key),
1189                "prev": shift_month(key, -1),
1190                "next": shift_month(key, 1),
1191                "today": prefix(ctx.now, 10),
1192                "days": days_json,
1193                "labels": labels,
1194                "counts": counts,
1195                "sums": sums,
1196            }),
1197        );
1198    }
1199    if !query.group.is_empty() {
1200        struct Group<'r, 'a> {
1201            parts: Vec<String>,
1202            rows: Vec<&'r RecordRowRef<'a>>,
1203            out: Map<String, Value>,
1204        }
1205        let mut groups: Vec<Group> = Vec::new();
1206        let mut index: HashMap<Vec<String>, usize> = HashMap::new();
1207        for row in &matched {
1208            let mut cols = Map::new();
1209            let parts: Vec<String> = query.group.iter().map(|key| group_columns(key, row, ctx.names, &mut cols)).collect();
1210            match index.get(&parts) {
1211                Some(&i) => groups[i].rows.push(row),
1212                None => {
1213                    index.insert(parts.clone(), groups.len());
1214                    groups.push(Group { parts, rows: vec![row], out: cols });
1215                }
1216            }
1217        }
1218        for g in &mut groups {
1219            let rows = g.rows.clone();
1220            summary(query, &rows, &mut g.out);
1221        }
1222        let mut columns: Vec<&str> = Vec::new();
1223        for g in &query.group {
1224            if g.field == "author" {
1225                columns.extend(["author", "authorId"]);
1226            } else {
1227                columns.push(&g.field);
1228            }
1229        }
1230        columns.push("count");
1231        if query.sum.is_some() {
1232            columns.push("sum");
1233        }
1234        columns.extend(query.aggregate.iter().map(|a| a.name.as_str()));
1235        let sort_keys: Vec<&RecordsSortKey> = query.sort.iter().filter(|k| columns.contains(&k.field.as_str())).collect();
1236        let col = |g: &Group, name: &str| -> Value { g.out.get(name).cloned().unwrap_or(Value::Null) };
1237        groups.sort_by(|a, b| {
1238            for key in &sort_keys {
1239                let (va, vb) = (col(a, &key.field), col(b, &key.field));
1240                let c = compare(&Field::Json(&va), &Field::Json(&vb));
1241                if c.is_ne() {
1242                    return if key.order == RecordsOrder::Asc { c } else { c.reverse() };
1243                }
1244            }
1245            for key in &query.group {
1246                let (va, vb) = (col(a, &key.field), col(b, &key.field));
1247                let c = compare(&Field::Json(&va), &Field::Json(&vb));
1248                if c.is_ne() {
1249                    return c;
1250                }
1251            }
1252            for (pa, pb) in a.parts.iter().zip(&b.parts) {
1253                let c = utf16_cmp(pa, pb);
1254                if c.is_ne() {
1255                    return c;
1256                }
1257            }
1258            Ordering::Equal
1259        });
1260        if !query.running.is_empty() {
1261            let mut r = seeded();
1262            for g in &mut groups {
1263                for row in &g.rows {
1264                    r.add(row);
1265                }
1266                g.out.insert("running".into(), r.read());
1267            }
1268        }
1269        out.insert("groups".into(), Value::Array(groups.into_iter().map(|g| Value::Object(g.out)).collect()));
1270    }
1271    if !query.aggregate.is_empty() {
1272        let mut totals = Map::new();
1273        summary(query, &matched, &mut totals);
1274        out.insert("totals".into(), Value::Object(totals));
1275    }
1276    if query.before.is_some() {
1277        let mut b = Map::new();
1278        summary(query, &before, &mut b);
1279        out.insert("before".into(), Value::Object(b));
1280    }
1281    Some(Value::Object(out))
1282}
1283
1284/// [`records_value`] over owned rows (the FFI's).
1285pub fn records_value_owned(rows: &[RecordRow], query: &RecordsQuery, ctx: RecordsContext) -> Option<Value> {
1286    let refs: Vec<RecordRowRef> = rows.iter().map(RecordRow::as_ref).collect();
1287    records_value(&refs, query, ctx)
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292    use super::*;
1293
1294    #[test]
1295    fn number_parses_like_js() {
1296        assert_eq!(js_string_number(" 12 "), 12.0);
1297        assert_eq!(js_string_number("0x10"), 16.0);
1298        assert_eq!(js_string_number("1e2"), 100.0);
1299        assert_eq!(js_string_number(""), 0.0);
1300        assert!(js_string_number("12px").is_nan());
1301        assert!(js_string_number("inf").is_nan());
1302        assert!(js_string_number("-0x1").is_nan());
1303        assert_eq!(js_string_number("-Infinity"), f64::NEG_INFINITY);
1304    }
1305
1306    #[test]
1307    fn months_shift_across_years() {
1308        assert_eq!(shift_month("2026-01", -1), "2025-12");
1309        assert_eq!(shift_month("2026-12", 1), "2027-01");
1310        assert_eq!(shift_month("2026-03", -15), "2024-12");
1311        assert_eq!(month_label("2026-10"), "October 2026");
1312        assert_eq!(month_days("2028-02").len(), 29);
1313    }
1314}