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