Skip to main content

eui_core/host/
actions.rs

1//! VAPP-194 (eApps E1, spec docs/spec/apps.md §4.5): typed effects, the
2//! twin of `packages/exponential-ui/src/host/actions.ts` (read its header).
3//!
4//! - EXPRESSIONS ARE PURE: [`validate_package_effects`] refuses a write or
5//!   host function in a template's pure places (the parser refuses one in a
6//!   CEL expression, [`crate::cel::function_effect`]).
7//! - ACTIONS (`actions: {<name>: {params?, steps}}`) are the only place a
8//!   package writes. [`run_action`] runs one against ONE snapshot and
9//!   returns the PLAN (`{writes, sets, calls}`) or the one error that
10//!   discards it; the host commits the writes as ONE unit (`records.batch`,
11//!   [`apply_record_batch`] over in-memory rows), applies the sets, runs the
12//!   calls. `fixtures/actions.json` locks it (TS, Rust, Swift, Kotlin).
13
14use serde_json::{json, Map, Value};
15
16use super::expressions::{literal_type, row_of, schema_type};
17use super::package::PackageIssue;
18use super::records::{validate_params_schema, validate_record, without_nulls};
19use super::utf16_cmp;
20use crate::cel::{check_expression, effect_words, evaluate_counted, is_optional, optional_type, parse_expression, required_type, type_display, type_name, ExpressionEnv, FunctionEffect, MAX_STEPS};
21
22pub use crate::cel::function_effect;
23
24/// Caps: actions per package, steps per action, and the evaluation steps
25/// ONE run may spend over all of its expressions (each keeps its own 10 000).
26pub const ACTION_LIMIT_ACTIONS: usize = 32;
27pub const ACTION_LIMIT_STEPS: usize = 64;
28pub const ACTION_LIMIT_MAX_STEPS: usize = 100_000;
29/// The step forms, by their one key.
30pub const ACTION_STEP_KINDS: &[&str] = &["require", "records.upsert", "records.delete", "set", "call"];
31/// An action's name (`actions.<name>`).
32pub const ACTION_NAME_PATTERN: &str = "^[a-z][A-Za-z0-9_]{0,31}$";
33/// The function-call prefix that runs a package action.
34pub const ACTION_CALL_PREFIX: &str = "actions.";
35/// The host function a host commits an action's record writes through.
36pub const RECORDS_BATCH_FUNCTION: &str = "records.batch";
37
38/// The snapshot an action runs against.
39#[derive(Default, Clone, Copy)]
40pub struct ActionContext<'a> {
41    /// The surface data model at the press (the `data` var; `None` = null).
42    pub data: Option<&'a Value>,
43    /// The member pressing (the `me` var; `None` = null).
44    pub me: Option<&'a str>,
45    /// The clock (epoch ms; `None` = the wall clock).
46    pub now: Option<f64>,
47    /// The team zone: minutes east of UTC at an instant (`None` = UTC).
48    pub offset: Option<&'a (dyn Fn(f64) -> i32 + Send + Sync)>,
49    /// The run's evaluation budget (default [`ACTION_LIMIT_MAX_STEPS`]).
50    pub max_steps: Option<usize>,
51}
52
53fn escape(key: &str) -> String {
54    key.replace('~', "~0").replace('/', "~1")
55}
56
57fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
58    PackageIssue { path: path.into(), message: message.into() }
59}
60
61fn ty(name: &str) -> Value {
62    Value::String(name.into())
63}
64
65/// `^(\/([^/~]|~[01])*)*$`: a JSON pointer.
66fn is_pointer(path: &str) -> bool {
67    if path.is_empty() {
68        return true;
69    }
70    if !path.starts_with('/') {
71        return false;
72    }
73    let mut chars = path.chars();
74    while let Some(c) = chars.next() {
75        if c == '~' && !matches!(chars.next(), Some('0' | '1')) {
76            return false;
77        }
78    }
79    true
80}
81
82/// `^[a-z][A-Za-z0-9_]{0,31}$`.
83pub fn is_action_name(name: &str) -> bool {
84    let b = name.as_bytes();
85    !b.is_empty() && b.len() <= 32 && b[0].is_ascii_lowercase() && b[1..].iter().all(|c| c.is_ascii_alphanumeric() || *c == b'_')
86}
87
88/// The action a call name runs (`actions.log` → `log`), else `None`.
89pub fn action_call_name(call: &str) -> Option<&str> {
90    call.strip_prefix(ACTION_CALL_PREFIX)
91}
92
93/// A step's one key, or `None`.
94fn step_kind(step: &Value) -> Option<&'static str> {
95    let step = step.as_object()?;
96    let keys: Vec<&'static str> = step.keys().filter_map(|k| ACTION_STEP_KINDS.iter().find(|s| **s == k.as_str()).copied()).collect();
97    if keys.len() == 1 {
98        Some(keys[0])
99    } else {
100        None
101    }
102}
103
104fn sorted_keys(m: &Map<String, Value>) -> Vec<&String> {
105    let mut keys: Vec<&String> = m.keys().collect();
106    keys.sort_by(|a, b| utf16_cmp(a, b));
107    keys
108}
109
110/// The names a step expression reads, typed: `params` (the action's
111/// params schema), `data` (the surface data: open), `me` (a member id).
112pub fn action_var_types(action: &Map<String, Value>) -> Map<String, Value> {
113    let schema = action.get("params").and_then(Value::as_object);
114    let empty = Map::new();
115    let props = schema.and_then(|s| s.get("properties")).and_then(Value::as_object).unwrap_or(&empty);
116    let mut types = Map::new();
117    types.insert("params".into(), row_of(props, schema.and_then(|s| s.get("required"))));
118    // VAPP-195: the surface data is untyped and the member may be unknown.
119    types.insert("data".into(), optional_type(ty("dyn")));
120    types.insert("me".into(), optional_type(ty("string")));
121    types
122}
123
124fn textual(t: &Value) -> bool {
125    matches!(t.as_str(), Some("string" | "date" | "timestamp"))
126}
127
128/// May a value of type `t` land in a field of type `field`?
129fn fits(field: &Value, t: &Value) -> bool {
130    // VAPP-195: a value that may be missing fits only a field that may be.
131    if is_optional(t) {
132        return is_optional(field) && fits(&required_type(field), &required_type(t));
133    }
134    if is_optional(field) {
135        return fits(&required_type(field), t);
136    }
137    if field == "dyn" || t == "dyn" || t == "null" {
138        return true;
139    }
140    if textual(field) {
141        return textual(t);
142    }
143    type_display(field) == type_display(t)
144}
145
146// ---------------------------------------------------------------------------
147// Validation (validate_package)
148// ---------------------------------------------------------------------------
149
150/// Every problem with a package's `actions`.
151pub fn validate_package_actions(pkg: &Map<String, Value>) -> Vec<PackageIssue> {
152    let mut issues = Vec::new();
153    let Some(actions) = pkg.get("actions") else { return issues };
154    let Some(actions) = actions.as_object() else {
155        issues.push(issue("/actions", "actions is an object of named actions"));
156        return issues;
157    };
158    let names = sorted_keys(actions);
159    if names.len() > ACTION_LIMIT_ACTIONS {
160        issues.push(issue("/actions", format!("at most {ACTION_LIMIT_ACTIONS} actions")));
161    }
162    for name in names {
163        issues.extend(validate_action(pkg, name, &actions[name.as_str()], &format!("/actions/{}", escape(name))));
164    }
165    issues
166}
167
168fn validate_action(pkg: &Map<String, Value>, name: &str, action: &Value, at: &str) -> Vec<PackageIssue> {
169    let mut issues = Vec::new();
170    if !is_action_name(name) {
171        issues.push(issue(at, "an action name is a letter, then letters, digits and underscores"));
172    }
173    let Some(action) = action.as_object() else {
174        issues.push(issue(at, "an action is {params?, steps}"));
175        return issues;
176    };
177    if let Some(params) = action.get("params") {
178        issues.extend(validate_params_schema(params, &format!("{at}/params")));
179    }
180    let steps = match action.get("steps").and_then(Value::as_array) {
181        Some(steps) if !steps.is_empty() => steps,
182        _ => {
183            issues.push(issue(format!("{at}/steps"), "steps is a non-empty array"));
184            return issues;
185        }
186    };
187    if steps.len() > ACTION_LIMIT_STEPS {
188        issues.push(issue(format!("{at}/steps"), format!("at most {ACTION_LIMIT_STEPS} steps")));
189    }
190    let types = action_var_types(action);
191    let empty = Map::new();
192    let records = pkg.get("records").and_then(Value::as_object).unwrap_or(&empty);
193    let functions: Option<Vec<&str>> = pkg.get("functions").and_then(Value::as_array).map(|l| l.iter().filter_map(Value::as_str).collect());
194    for (i, step) in steps.iter().enumerate() {
195        issues.extend(validate_step(step, &format!("{at}/steps/{i}"), &types, records, functions.as_deref()));
196    }
197    issues
198}
199
200/// An expression slot: a string = CEL (type-checked), anything else = a
201/// literal. The type, or `None` after an issue.
202fn slot(value: &Value, at: &str, types: &Map<String, Value>, issues: &mut Vec<PackageIssue>) -> Option<Value> {
203    let Value::String(source) = value else { return Some(literal_type(value)) };
204    match check_expression(source, types) {
205        Ok(t) => Some(t),
206        Err(message) => {
207            issues.push(issue(at, message));
208            None
209        }
210    }
211}
212
213fn validate_step(step: &Value, at: &str, types: &Map<String, Value>, records: &Map<String, Value>, functions: Option<&[&str]>) -> Vec<PackageIssue> {
214    let mut issues = Vec::new();
215    let (Some(kind), Some(step)) = (step_kind(step), step.as_object()) else {
216        return vec![issue(at, format!("a step is one of {}", ACTION_STEP_KINDS.join(", ")))];
217    };
218    let body = &step[kind];
219    let here = format!("{at}/{}", escape(kind));
220    match kind {
221        "require" => {
222            if body.is_string() {
223                if let Some(t) = slot(body, &here, types, &mut issues) {
224                    if t != "bool" && t != "dyn" {
225                        issues.push(issue(&here, format!("require needs a bool, got {}", type_display(&t))));
226                    }
227                }
228            } else {
229                issues.push(issue(&here, "require is an expression"));
230            }
231            if !step.get("message").and_then(Value::as_str).is_some_and(|m| !m.is_empty()) {
232                issues.push(issue(format!("{at}/message"), "message is the text shown when the requirement fails"));
233            }
234        }
235        "records.upsert" => {
236            let Some(body) = body.as_object() else { return vec![issue(&here, "records.upsert is {kind, data, id?}")] };
237            let kind_name = body.get("kind").and_then(Value::as_str).unwrap_or("");
238            let def = records.get(kind_name).and_then(Value::as_object);
239            if def.is_none() {
240                issues.push(issue(format!("{here}/kind"), format!("records:{kind_name} names no declared kind")));
241            }
242            // VAPP-206: an id that may be missing = create or patch.
243            let mut may_create = body.get("id").is_none();
244            if let Some(id) = body.get("id") {
245                let id_at = format!("{here}/id");
246                if let Some(t) = slot(id, &id_at, types, &mut issues) {
247                    if !fits(&optional_type(ty("string")), &t) {
248                        issues.push(issue(&id_at, format!("id is a string, got {}", type_display(&t))));
249                    }
250                    if is_optional(&t) {
251                        may_create = true;
252                    }
253                }
254            }
255            let Some(data) = body.get("data").and_then(Value::as_object) else {
256                issues.push(issue(format!("{here}/data"), "data is an object of field expressions"));
257                return issues;
258            };
259            let schema = def.and_then(|d| d.get("schema")).and_then(Value::as_object);
260            let empty = Map::new();
261            let props = schema.and_then(|s| s.get("properties")).and_then(Value::as_object).unwrap_or(&empty);
262            for field in sorted_keys(data) {
263                let field_at = format!("{here}/data/{}", escape(field));
264                let t = slot(&data[field.as_str()], &field_at, types, &mut issues);
265                let Some(schema) = schema else { continue };
266                let Some(prop) = props.get(field.as_str()) else {
267                    if schema.get("additionalProperties") != Some(&Value::Bool(true)) {
268                        issues.push(issue(&field_at, format!("{field} is not in the schema of {kind_name}")));
269                    }
270                    continue;
271                };
272                let required = schema.get("required").and_then(Value::as_array).is_some_and(|r| r.iter().any(|x| x.as_str() == Some(field.as_str())));
273                let want = if required { schema_type(Some(prop)) } else { optional_type(schema_type(Some(prop))) };
274                if let Some(t) = t {
275                    if !fits(&want, &t) {
276                        issues.push(issue(&field_at, format!("{field} is {}, got {}", type_display(&want), type_display(&t))));
277                    }
278                }
279            }
280            // A create names every required field (a patch keeps the row's).
281            if let (Some(schema), true) = (schema, may_create) {
282                if let Some(required) = schema.get("required").and_then(Value::as_array) {
283                    for field in required.iter().filter_map(Value::as_str) {
284                        if !data.contains_key(field) {
285                            issues.push(issue(format!("{here}/data"), format!("{field} is required by {kind_name}")));
286                        }
287                    }
288                }
289            }
290        }
291        "records.delete" => {
292            let Some(id) = body.as_object().and_then(|b| b.get("id")) else { return vec![issue(&here, "records.delete is {id}")] };
293            let id_at = format!("{here}/id");
294            if let Some(t) = slot(id, &id_at, types, &mut issues) {
295                if !fits(&ty("string"), &t) {
296                    issues.push(issue(&id_at, format!("id is a string, got {}", type_display(&t))));
297                }
298            }
299        }
300        "set" => {
301            let body = body.as_object();
302            if !body.and_then(|b| b.get("path")).and_then(Value::as_str).is_some_and(is_pointer) {
303                issues.push(issue(format!("{here}/path"), "path is a JSON pointer"));
304            }
305            if let Some(body) = body {
306                slot(body.get("value").unwrap_or(&Value::Null), &format!("{here}/value"), types, &mut issues);
307            }
308        }
309        "call" => {
310            let Some(name) = body.as_str().filter(|n| !n.is_empty()) else { return vec![issue(&here, "call names a host function")] };
311            let effect = function_effect(name);
312            if effect == FunctionEffect::Write {
313                issues.push(issue(&here, format!("{}: use a records.upsert, records.delete or set step", effect_words(name, effect))));
314            } else if effect == FunctionEffect::Pure {
315                issues.push(issue(&here, format!("{name} is pure: compute it in an expression")));
316            } else if action_call_name(name).is_some() {
317                issues.push(issue(&here, "an action never runs another action"));
318            } else if let Some(functions) = functions.filter(|_| name != super::screens::NAVIGATE_FUNCTION) {
319                if !functions.iter().any(|p| *p == name || p.strip_suffix('*').is_some_and(|prefix| name.starts_with(prefix))) {
320                    issues.push(issue(&here, format!("{name} is not in the package's functions")));
321                }
322            }
323            if let Some(args) = step.get("args") {
324                match args.as_object() {
325                    None => issues.push(issue(format!("{at}/args"), "args is an object of expressions")),
326                    Some(args) => {
327                        for k in sorted_keys(args) {
328                            slot(&args[k.as_str()], &format!("{at}/args/{}", escape(k)), types, &mut issues);
329                        }
330                    }
331                }
332            }
333        }
334        _ => {}
335    }
336    issues
337}
338
339fn pure(value: &Value, at: &str, issues: &mut Vec<PackageIssue>) {
340    match value {
341        Value::Array(list) => {
342            for (i, v) in list.iter().enumerate() {
343                pure(v, &format!("{at}/{i}"), issues);
344            }
345        }
346        Value::Object(obj) => {
347            if let Some(call) = obj.get("call").and_then(Value::as_str) {
348                let effect = function_effect(call);
349                if effect != FunctionEffect::Pure {
350                    issues.push(issue(format!("{at}/call"), format!("{}: a binding is pure, name an action in on.press", effect_words(call, effect))));
351                }
352            }
353            for (k, v) in obj {
354                pure(v, &format!("{at}/{}", escape(k)), issues);
355            }
356        }
357        _ => {}
358    }
359}
360
361/// VAPP-194: every function call in a template's PURE places (component
362/// props, a call's args, an event's context) is pure; an action's
363/// `functionCall` may name a host or write function, and an
364/// `actions.<name>` one names a declared action.
365pub fn validate_package_effects(pkg: &Map<String, Value>) -> Vec<PackageIssue> {
366    let mut issues = Vec::new();
367    let Some(templates) = pkg.get("templates").and_then(Value::as_object) else { return issues };
368    let empty = Map::new();
369    let actions = pkg.get("actions").and_then(Value::as_object).unwrap_or(&empty);
370    for (id, template) in super::package::sorted_entries(templates) {
371        let Some(components) = template.as_object().and_then(|t| t.get("components")).and_then(Value::as_array) else { continue };
372        for (i, c) in components.iter().enumerate() {
373            let Some(c) = c.as_object() else { continue };
374            let at = format!("/templates/{}/components/{i}", escape(id));
375            for (k, v) in c {
376                if k != "on" {
377                    pure(v, &format!("{at}/{}", escape(k)), &mut issues);
378                    continue;
379                }
380                let Some(on) = v.as_object() else { continue };
381                for (event, handler) in on {
382                    let hat = format!("{at}/on/{}", escape(event));
383                    let Some(handler) = handler.as_object() else { continue };
384                    for (hk, hv) in handler {
385                        let fc = hv.as_object().filter(|_| hk == "functionCall");
386                        let Some(fc) = fc else {
387                            pure(hv, &format!("{hat}/{}", escape(hk)), &mut issues);
388                            continue;
389                        };
390                        let call = fc.get("call").and_then(Value::as_str).unwrap_or("");
391                        if let Some(action) = action_call_name(call) {
392                            if !actions.contains_key(action) {
393                                issues.push(issue(format!("{hat}/functionCall/call"), format!("action {action} names no package action")));
394                            }
395                            // VAPP-204: a press's args are the action's params.
396                            if let Some(def) = actions.get(action).and_then(Value::as_object) {
397                                press_arg_issues(action, def, fc.get("args"), &format!("{hat}/functionCall/args"), &mut issues);
398                            }
399                        }
400                        // VAPP-204: a bind's source is one the package may follow.
401                        if call == "bind" {
402                            bind_source_issues(pkg, fc.get("args"), &format!("{hat}/functionCall/args"), &mut issues);
403                        }
404                        for (fk, fv) in fc {
405                            if fk != "call" {
406                                pure(fv, &format!("{hat}/functionCall/{}", escape(fk)), &mut issues);
407                            }
408                        }
409                    }
410                }
411            }
412        }
413    }
414    issues
415}
416
417/// VAPP-204: a press's `actions.<name>` args against the action's params:
418/// every key a param (unless the schema allows additional properties),
419/// every required param given, a literal scalar of the param's type (a
420/// dynamic value is checked when it runs).
421fn press_arg_issues(name: &str, action: &Map<String, Value>, args: Option<&Value>, at: &str, issues: &mut Vec<PackageIssue>) {
422    if args.is_some_and(|a| !a.is_object()) {
423        issues.push(issue(at, format!("args is an object of action {name}'s params")));
424        return;
425    }
426    let empty = Map::new();
427    let schema = action.get("params").and_then(Value::as_object).unwrap_or(&empty);
428    let props = schema.get("properties").and_then(Value::as_object).unwrap_or(&empty);
429    let required: Vec<&str> = schema.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
430    let given = args.and_then(Value::as_object).unwrap_or(&empty);
431    let mut keys: Vec<&String> = given.keys().collect();
432    keys.sort();
433    for key in keys {
434        let here = format!("{at}/{}", escape(key));
435        let Some(prop) = props.get(key) else {
436            if schema.get("additionalProperties") != Some(&Value::Bool(true)) {
437                issues.push(issue(here, format!("{key} is not a param of action {name}")));
438            }
439            continue;
440        };
441        let v = &given[key];
442        if !(v.is_string() || v.is_number() || v.is_boolean()) {
443            continue;
444        }
445        let want = if required.contains(&key.as_str()) { schema_type(Some(prop)) } else { optional_type(schema_type(Some(prop))) };
446        let t = literal_type(v);
447        if !fits(&want, &t) {
448            issues.push(issue(here, format!("{key} is {}, got {}", type_display(&want), type_display(&t))));
449        }
450    }
451    for field in required {
452        if !given.contains_key(field) {
453            issues.push(issue(at, format!("{field} is required by action {name}")));
454        }
455    }
456}
457
458/// VAPP-204: a `bind` press's literal source, checked like a template
459/// binding: a scheme:name URI, in the package's `sources`, a declared kind
460/// and a package query.
461fn bind_source_issues(pkg: &Map<String, Value>, args: Option<&Value>, at: &str, issues: &mut Vec<PackageIssue>) {
462    let Some(source) = args.and_then(|a| a.get("source")).and_then(Value::as_str) else { return };
463    let here = format!("{at}/source");
464    let Some(parsed) = super::sources::parse_source(source) else {
465        issues.push(issue(here, "source is a scheme:name URI"));
466        return;
467    };
468    if let Some(list) = pkg.get("sources").and_then(Value::as_array).filter(|l| l.iter().all(Value::is_string)) {
469        let allowed: Vec<String> = list.iter().filter_map(Value::as_str).map(str::to_string).collect();
470        if !super::package::source_allowed(Some(&allowed), source) {
471            issues.push(issue(&here, format!("source {source} is not in the package's sources")));
472        }
473    }
474    if parsed.scheme == super::record_store::RECORDS_SCHEME {
475        if !pkg.get("records").and_then(Value::as_object).is_some_and(|r| r.contains_key(&parsed.name)) {
476            issues.push(issue(&here, format!("records:{} names no declared kind", parsed.name)));
477        }
478        if let Some(query) = parsed.params.get("query") {
479            if !pkg.get("queries").and_then(Value::as_object).is_some_and(|q| q.contains_key(query)) {
480                issues.push(issue(&here, format!("query {query} names no package query")));
481            }
482        }
483    }
484}
485
486// ---------------------------------------------------------------------------
487// Running (pure: the plan, or the one error)
488// ---------------------------------------------------------------------------
489
490fn issue_text(issues: &[PackageIssue]) -> String {
491    issues.iter().map(|i| format!("{} {}", if i.path.is_empty() { "/" } else { &i.path }, i.message)).collect::<Vec<_>>().join("; ")
492}
493
494/// VAPP-207: a bad param in words (the toast): `stage must be one of a, b`.
495fn param_issue_text(issues: &[PackageIssue]) -> String {
496    let text = |i: &PackageIssue| {
497        let field = i.path.strip_prefix('/').unwrap_or(&i.path);
498        if i.message == "required" || i.message == "not in the schema" {
499            format!("{field} is {}", i.message)
500        } else if i.message.starts_with("one of ") {
501            format!("{field} must be {}", i.message.replace('|', ", "))
502        } else {
503            format!("{field} must be {}", i.message)
504        }
505    };
506    issues.iter().map(text).collect::<Vec<_>>().join("; ")
507}
508
509/// Run a package action against one snapshot: its plan `{writes, sets,
510/// calls}`, or the error that discards it whole (a false `require` = its
511/// message; else `step <i>: …`).
512pub fn run_action(pkg: &Value, name: &str, params: &Value, ctx: &ActionContext) -> Result<Value, String> {
513    let action = pkg.get("actions").and_then(Value::as_object).and_then(|a| a.get(name)).and_then(Value::as_object);
514    let Some((action, steps)) = action.and_then(|a| a.get("steps").and_then(Value::as_array).map(|s| (a, s))) else { return Err(format!("no action {name}")) };
515    let p = if params.is_null() { Value::Object(Map::new()) } else { params.clone() };
516    if !p.is_object() {
517        return Err("params is an object".into());
518    }
519    if let Some(schema) = action.get("params").filter(|s| s.is_object()) {
520        // VAPP-206: an optional param left "" (a Select unpicked) passes as absent.
521        let required = schema.get("required").and_then(Value::as_array);
522        let mut given = p.clone();
523        if let Some(m) = given.as_object_mut() {
524            m.retain(|k, v| v.as_str() != Some("") || required.is_some_and(|r| r.iter().any(|x| x.as_str() == Some(k.as_str()))));
525        }
526        let issues = validate_record(schema, &given);
527        if !issues.is_empty() {
528            return Err(param_issue_text(&issues));
529        }
530    }
531    let mut vars = Map::new();
532    vars.insert("params".into(), p);
533    vars.insert("data".into(), ctx.data.cloned().unwrap_or(Value::Null));
534    vars.insert("me".into(), ctx.me.map_or(Value::Null, |m| Value::String(m.into())));
535    let empty = Map::new();
536    let records = pkg.get("records").and_then(Value::as_object).unwrap_or(&empty);
537    let mut budget: i64 = ctx.max_steps.unwrap_or(ACTION_LIMIT_MAX_STEPS) as i64;
538    let mut writes = Vec::new();
539    let mut sets = Vec::new();
540    let mut calls = Vec::new();
541    for (i, step) in steps.iter().enumerate() {
542        let r#where = format!("step {i}");
543        let limit = || format!("{where}: action step limit exceeded");
544        budget -= 1;
545        if budget < 0 {
546            return Err(limit());
547        }
548        let mut value = |v: &Value| -> Result<Value, String> {
549            let Value::String(source) = v else { return Ok(v.clone()) };
550            let ast = parse_expression(source).map_err(|e| format!("{where}: {e}"))?;
551            let cap = (MAX_STEPS as i64).min(budget);
552            let env = ExpressionEnv { now: ctx.now, offset: ctx.offset, max_steps: Some(cap.max(0) as usize) };
553            let (result, used) = evaluate_counted(&ast, &vars, &env);
554            match result {
555                Err(e) if e == "step limit exceeded" && cap == budget => Err(limit()),
556                Err(e) => Err(format!("{where}: {e}")),
557                Ok(v) => {
558                    budget -= used as i64;
559                    if budget < 0 {
560                        return Err(limit());
561                    }
562                    Ok(v)
563                }
564            }
565        };
566        let (Some(kind), Some(step)) = (step_kind(step), step.as_object()) else {
567            return Err(format!("{where}: a step is one of {}", ACTION_STEP_KINDS.join(", ")));
568        };
569        let body = &step[kind];
570        match kind {
571            "require" => {
572                let ok = value(body)?;
573                let Value::Bool(ok) = ok else { return Err(format!("{where}: require needs a bool, got {}", type_name(&ok))) };
574                if !ok {
575                    return Err(match step.get("message").and_then(Value::as_str).filter(|m| !m.is_empty()) {
576                        Some(m) => m.to_string(),
577                        None => format!("{where}: requirement failed"),
578                    });
579                }
580            }
581            "records.upsert" => {
582                let Some((body, data_exprs)) = body.as_object().and_then(|b| b.get("data").and_then(Value::as_object).map(|d| (b, d))) else {
583                    return Err(format!("{where}: records.upsert is {{kind, data, id?}}"));
584                };
585                let kind_name = body.get("kind").and_then(Value::as_str).unwrap_or("");
586                let Some(schema) = records.get(kind_name).and_then(Value::as_object).and_then(|d| d.get("schema")).filter(|s| s.is_object()) else {
587                    return Err(format!("{where}: records: no kind {kind_name}"));
588                };
589                // VAPP-206: a null or empty id = a create, a string = a patch.
590                let mut id = None;
591                if let Some(expr) = body.get("id") {
592                    match value(expr)? {
593                        Value::Null => {}
594                        Value::String(s) => {
595                            if !s.is_empty() {
596                                id = Some(s);
597                            }
598                        }
599                        other => return Err(format!("{where}: id is a string, got {}", type_name(&other))),
600                    }
601                }
602                let required = schema.get("required").and_then(Value::as_array);
603                let mut data = Map::new();
604                for field in sorted_keys(data_exprs) {
605                    let raw = &data_exprs[field.as_str()];
606                    let v = value(raw)?;
607                    // VAPP-206: absent stays absent. An expression's null (a param
608                    // left out) writes nothing; "" in an optional field (an
609                    // unpicked Select, an emptied Input) is no value. On a patch a
610                    // literal null and that "" clear the field.
611                    let is_required = required.is_some_and(|r| r.iter().any(|x| x.as_str() == Some(field.as_str())));
612                    let none = v.is_null() || (v.as_str() == Some("") && !is_required);
613                    if none && (id.is_none() || (v.is_null() && raw.is_string())) {
614                        continue;
615                    }
616                    data.insert(field.clone(), if none { Value::Null } else { v });
617                }
618                let data = Value::Object(data);
619                // A patch is checked field by field (the store merges and checks the row).
620                let issues = if id.is_none() {
621                    validate_record(schema, &data)
622                } else {
623                    let mut patch = schema.clone();
624                    patch["required"] = json!([]);
625                    validate_record(&patch, &data)
626                };
627                if !issues.is_empty() {
628                    return Err(format!("{where}: records: {}", issue_text(&issues)));
629                }
630                writes.push(match id {
631                    None => json!({"op": "upsert", "kind": kind_name, "data": data}),
632                    Some(id) => json!({"op": "upsert", "kind": kind_name, "data": data, "id": id}),
633                });
634            }
635            "records.delete" => {
636                let Some(body) = body.as_object() else { return Err(format!("{where}: records.delete is {{id}}")) };
637                // A missing id evaluates like JS `undefined` (not a string).
638                let v = match body.get("id") {
639                    Some(expr) => value(expr)?,
640                    None => return Err(format!("{where}: id is a string, got null")),
641                };
642                match v {
643                    Value::String(s) if !s.is_empty() => writes.push(json!({"op": "delete", "id": s})),
644                    other => return Err(format!("{where}: id is a string, got {}", type_name(&other))),
645                }
646            }
647            "set" => {
648                let Some((body, path)) = body.as_object().and_then(|b| b.get("path").and_then(Value::as_str).filter(|p| is_pointer(p)).map(|p| (b, p))) else {
649                    return Err(format!("{where}: path is a JSON pointer"));
650                };
651                let v = value(body.get("value").unwrap_or(&Value::Null))?;
652                sets.push(json!({"path": path, "value": v}));
653            }
654            "call" => {
655                let Some(name) = body.as_str().filter(|n| function_effect(n) == FunctionEffect::Host && action_call_name(n).is_none()) else {
656                    return Err(format!("{where}: call names a host function"));
657                };
658                let mut args = Map::new();
659                if let Some(given) = step.get("args").and_then(Value::as_object) {
660                    for k in sorted_keys(given) {
661                        args.insert(k.clone(), value(&given[k.as_str()])?);
662                    }
663                }
664                calls.push(json!({"name": name, "args": args}));
665            }
666            _ => {}
667        }
668    }
669    Ok(json!({"writes": writes, "sets": sets, "calls": calls}))
670}
671
672// ---------------------------------------------------------------------------
673// The batch (one unit over in-memory rows)
674// ---------------------------------------------------------------------------
675
676/// Apply a batch of writes (`{op: "upsert", kind, data, id?}` | `{op:
677/// "delete", id}`) to `rows` (`{id, kind, data, createdBy, createdAt,
678/// updatedAt}`), all or nothing: the new rows and each write's id, or the
679/// first refusal (`write <i>: …`). A patch merges onto its row and the
680/// merged row is checked; a create's id is `new_id(<i-th create>)`, its
681/// `createdBy` = `me`, every touched row stamped `now`.
682pub fn apply_record_batch(rows: &[Value], writes: &[Value], records: Option<&Value>, me: Option<&str>, now: &str, new_id: impl FnMut(usize) -> String) -> Result<(Vec<Value>, Vec<String>), String> {
683    apply_record_batch_as(rows, writes, records, me, None, None, now, new_id)
684}
685
686/// eApps R3: [`apply_record_batch`] with every write judged by its kind's
687/// policy for the writer `me` with team `role` and app `access` (TS
688/// `BatchOptions.role` / `access`); `role` `None` = not judged.
689#[allow(clippy::too_many_arguments)]
690pub fn apply_record_batch_as(
691    rows: &[Value],
692    writes: &[Value],
693    records: Option<&Value>,
694    me: Option<&str>,
695    role: Option<&str>,
696    access: Option<&str>,
697    now: &str,
698    mut new_id: impl FnMut(usize) -> String,
699) -> Result<(Vec<Value>, Vec<String>), String> {
700    use super::record_policy::{record_policy, record_refusal, RecordOp, RecordViewer};
701    let judged = role.is_some();
702    let viewer = RecordViewer { id: me, role, access };
703    let kind_def = |kind: &str| records.and_then(|r| r.get(kind));
704    let judge = |kind: &str, op: RecordOp, row: Option<&Value>| if judged { record_refusal(&record_policy(kind_def(kind)), op, &viewer, row) } else { None };
705    // A row the writer may not read is not there for them.
706    let readable = |r: &Value| !judged || record_refusal(&record_policy(kind_def(r.get("kind").and_then(Value::as_str).unwrap_or(""))), RecordOp::Read, &viewer, Some(r)).is_none();
707    let mut out: Vec<Value> = rows.to_vec();
708    let mut ids = Vec::new();
709    let mut creates = 0;
710    let row_id = |r: &Value| r.get("id").and_then(Value::as_str).map(str::to_string);
711    for (i, w) in writes.iter().enumerate() {
712        let r#where = format!("write {i}");
713        let wid = w.get("id").map(|v| v.as_str().map_or_else(|| v.to_string(), str::to_string));
714        if w.get("op").and_then(Value::as_str) == Some("delete") {
715            let id = wid.unwrap_or_else(|| "undefined".into());
716            let Some(row) = out.iter().find(|r| row_id(r).as_deref() == Some(id.as_str()) && readable(r)) else {
717                return Err(format!("{where}: record {id} not found"));
718            };
719            if let Some(refusal) = judge(row.get("kind").and_then(Value::as_str).unwrap_or(""), RecordOp::Delete, Some(row)) {
720                return Err(format!("{where}: {refusal}"));
721            }
722            out.retain(|r| row_id(r).as_deref() != Some(id.as_str()));
723            ids.push(id);
724            continue;
725        }
726        let kind = w.get("kind").and_then(Value::as_str).unwrap_or("undefined");
727        let Some(schema) = records.and_then(Value::as_object).and_then(|r| r.get(kind)).and_then(Value::as_object).and_then(|d| d.get("schema")).filter(|s| s.is_object()) else {
728            return Err(format!("{where}: records: no kind {kind}"));
729        };
730        let wdata = w.get("data").cloned().unwrap_or(Value::Null);
731        if let Some(id) = wid {
732            let Some(index) = out.iter().position(|r| row_id(r).as_deref() == Some(id.as_str()) && readable(r)) else { return Err(format!("{where}: record {id} not found")) };
733            if out[index].get("kind").and_then(Value::as_str) != Some(kind) {
734                return Err(format!("{where}: record {id} is not of kind {kind}"));
735            }
736            if let Some(refusal) = judge(kind, RecordOp::Update, Some(&out[index])) {
737                return Err(format!("{where}: {refusal}"));
738            }
739            let mut data = out[index].get("data").and_then(Value::as_object).cloned().unwrap_or_default();
740            if let Some(patch) = wdata.as_object() {
741                for (k, v) in patch {
742                    data.insert(k.clone(), v.clone());
743                }
744            }
745            let data = Value::Object(data);
746            let issues = validate_record(schema, &data);
747            if !issues.is_empty() {
748                return Err(format!("{where}: records: {}", issue_text(&issues)));
749            }
750            if let Some(row) = out[index].as_object_mut() {
751                row.insert("data".into(), data);
752                row.insert("updatedAt".into(), Value::String(now.into()));
753            }
754            ids.push(id);
755            continue;
756        }
757        if let Some(refusal) = judge(kind, RecordOp::Create, None) {
758            return Err(format!("{where}: {refusal}"));
759        }
760        let wdata = without_nulls(&wdata);
761        let issues = validate_record(schema, &wdata);
762        if !issues.is_empty() {
763            return Err(format!("{where}: records: {}", issue_text(&issues)));
764        }
765        let id = new_id(creates);
766        creates += 1;
767        out.push(json!({"id": id, "kind": kind, "data": wdata, "createdBy": me, "authorId": me, "createdAt": now, "updatedAt": now}));
768        ids.push(id);
769    }
770    Ok((out, ids))
771}