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_record, validate_record_schema};
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_record_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            if let Some(id) = body.get("id") {
243                let id_at = format!("{here}/id");
244                if let Some(t) = slot(id, &id_at, types, &mut issues) {
245                    if !fits(&ty("string"), &t) {
246                        issues.push(issue(&id_at, format!("id is a string, got {}", type_display(&t))));
247                    }
248                }
249            }
250            let Some(data) = body.get("data").and_then(Value::as_object) else {
251                issues.push(issue(format!("{here}/data"), "data is an object of field expressions"));
252                return issues;
253            };
254            let schema = def.and_then(|d| d.get("schema")).and_then(Value::as_object);
255            let empty = Map::new();
256            let props = schema.and_then(|s| s.get("properties")).and_then(Value::as_object).unwrap_or(&empty);
257            for field in sorted_keys(data) {
258                let field_at = format!("{here}/data/{}", escape(field));
259                let t = slot(&data[field.as_str()], &field_at, types, &mut issues);
260                let Some(schema) = schema else { continue };
261                let Some(prop) = props.get(field.as_str()) else {
262                    if schema.get("additionalProperties") != Some(&Value::Bool(true)) {
263                        issues.push(issue(&field_at, format!("{field} is not in the schema of {kind_name}")));
264                    }
265                    continue;
266                };
267                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())));
268                let want = if required { schema_type(Some(prop)) } else { optional_type(schema_type(Some(prop))) };
269                if let Some(t) = t {
270                    if !fits(&want, &t) {
271                        issues.push(issue(&field_at, format!("{field} is {}, got {}", type_display(&want), type_display(&t))));
272                    }
273                }
274            }
275            // A create names every required field (a patch keeps the row's).
276            if let (Some(schema), None) = (schema, body.get("id")) {
277                if let Some(required) = schema.get("required").and_then(Value::as_array) {
278                    for field in required.iter().filter_map(Value::as_str) {
279                        if !data.contains_key(field) {
280                            issues.push(issue(format!("{here}/data"), format!("{field} is required by {kind_name}")));
281                        }
282                    }
283                }
284            }
285        }
286        "records.delete" => {
287            let Some(id) = body.as_object().and_then(|b| b.get("id")) else { return vec![issue(&here, "records.delete is {id}")] };
288            let id_at = format!("{here}/id");
289            if let Some(t) = slot(id, &id_at, types, &mut issues) {
290                if !fits(&ty("string"), &t) {
291                    issues.push(issue(&id_at, format!("id is a string, got {}", type_display(&t))));
292                }
293            }
294        }
295        "set" => {
296            let body = body.as_object();
297            if !body.and_then(|b| b.get("path")).and_then(Value::as_str).is_some_and(is_pointer) {
298                issues.push(issue(format!("{here}/path"), "path is a JSON pointer"));
299            }
300            if let Some(body) = body {
301                slot(body.get("value").unwrap_or(&Value::Null), &format!("{here}/value"), types, &mut issues);
302            }
303        }
304        "call" => {
305            let Some(name) = body.as_str().filter(|n| !n.is_empty()) else { return vec![issue(&here, "call names a host function")] };
306            let effect = function_effect(name);
307            if effect == FunctionEffect::Write {
308                issues.push(issue(&here, format!("{}: use a records.upsert, records.delete or set step", effect_words(name, effect))));
309            } else if effect == FunctionEffect::Pure {
310                issues.push(issue(&here, format!("{name} is pure: compute it in an expression")));
311            } else if action_call_name(name).is_some() {
312                issues.push(issue(&here, "an action never runs another action"));
313            } else if let Some(functions) = functions {
314                if !functions.iter().any(|p| *p == name || p.strip_suffix('*').is_some_and(|prefix| name.starts_with(prefix))) {
315                    issues.push(issue(&here, format!("{name} is not in the package's functions")));
316                }
317            }
318            if let Some(args) = step.get("args") {
319                match args.as_object() {
320                    None => issues.push(issue(format!("{at}/args"), "args is an object of expressions")),
321                    Some(args) => {
322                        for k in sorted_keys(args) {
323                            slot(&args[k.as_str()], &format!("{at}/args/{}", escape(k)), types, &mut issues);
324                        }
325                    }
326                }
327            }
328        }
329        _ => {}
330    }
331    issues
332}
333
334fn pure(value: &Value, at: &str, issues: &mut Vec<PackageIssue>) {
335    match value {
336        Value::Array(list) => {
337            for (i, v) in list.iter().enumerate() {
338                pure(v, &format!("{at}/{i}"), issues);
339            }
340        }
341        Value::Object(obj) => {
342            if let Some(call) = obj.get("call").and_then(Value::as_str) {
343                let effect = function_effect(call);
344                if effect != FunctionEffect::Pure {
345                    issues.push(issue(format!("{at}/call"), format!("{}: a binding is pure, name an action in on.press", effect_words(call, effect))));
346                }
347            }
348            for (k, v) in obj {
349                pure(v, &format!("{at}/{}", escape(k)), issues);
350            }
351        }
352        _ => {}
353    }
354}
355
356/// VAPP-194: every function call in a template's PURE places (component
357/// props, a call's args, an event's context) is pure; an action's
358/// `functionCall` may name a host or write function, and an
359/// `actions.<name>` one names a declared action.
360pub fn validate_package_effects(pkg: &Map<String, Value>) -> Vec<PackageIssue> {
361    let mut issues = Vec::new();
362    let Some(templates) = pkg.get("templates").and_then(Value::as_object) else { return issues };
363    let empty = Map::new();
364    let actions = pkg.get("actions").and_then(Value::as_object).unwrap_or(&empty);
365    for (id, template) in super::package::sorted_entries(templates) {
366        let Some(components) = template.as_object().and_then(|t| t.get("components")).and_then(Value::as_array) else { continue };
367        for (i, c) in components.iter().enumerate() {
368            let Some(c) = c.as_object() else { continue };
369            let at = format!("/templates/{}/components/{i}", escape(id));
370            for (k, v) in c {
371                if k != "on" {
372                    pure(v, &format!("{at}/{}", escape(k)), &mut issues);
373                    continue;
374                }
375                let Some(on) = v.as_object() else { continue };
376                for (event, handler) in on {
377                    let hat = format!("{at}/on/{}", escape(event));
378                    let Some(handler) = handler.as_object() else { continue };
379                    for (hk, hv) in handler {
380                        let fc = hv.as_object().filter(|_| hk == "functionCall");
381                        let Some(fc) = fc else {
382                            pure(hv, &format!("{hat}/{}", escape(hk)), &mut issues);
383                            continue;
384                        };
385                        let call = fc.get("call").and_then(Value::as_str).unwrap_or("");
386                        if let Some(action) = action_call_name(call) {
387                            if !actions.contains_key(action) {
388                                issues.push(issue(format!("{hat}/functionCall/call"), format!("action {action} names no package action")));
389                            }
390                            // VAPP-204: a press's args are the action's params.
391                            if let Some(def) = actions.get(action).and_then(Value::as_object) {
392                                press_arg_issues(action, def, fc.get("args"), &format!("{hat}/functionCall/args"), &mut issues);
393                            }
394                        }
395                        // VAPP-204: a bind's source is one the package may follow.
396                        if call == "bind" {
397                            bind_source_issues(pkg, fc.get("args"), &format!("{hat}/functionCall/args"), &mut issues);
398                        }
399                        for (fk, fv) in fc {
400                            if fk != "call" {
401                                pure(fv, &format!("{hat}/functionCall/{}", escape(fk)), &mut issues);
402                            }
403                        }
404                    }
405                }
406            }
407        }
408    }
409    issues
410}
411
412/// VAPP-204: a press's `actions.<name>` args against the action's params:
413/// every key a param (unless the schema allows additional properties),
414/// every required param given, a literal scalar of the param's type (a
415/// dynamic value is checked when it runs).
416fn press_arg_issues(name: &str, action: &Map<String, Value>, args: Option<&Value>, at: &str, issues: &mut Vec<PackageIssue>) {
417    if args.is_some_and(|a| !a.is_object()) {
418        issues.push(issue(at, format!("args is an object of action {name}'s params")));
419        return;
420    }
421    let empty = Map::new();
422    let schema = action.get("params").and_then(Value::as_object).unwrap_or(&empty);
423    let props = schema.get("properties").and_then(Value::as_object).unwrap_or(&empty);
424    let required: Vec<&str> = schema.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
425    let given = args.and_then(Value::as_object).unwrap_or(&empty);
426    let mut keys: Vec<&String> = given.keys().collect();
427    keys.sort();
428    for key in keys {
429        let here = format!("{at}/{}", escape(key));
430        let Some(prop) = props.get(key) else {
431            if schema.get("additionalProperties") != Some(&Value::Bool(true)) {
432                issues.push(issue(here, format!("{key} is not a param of action {name}")));
433            }
434            continue;
435        };
436        let v = &given[key];
437        if !(v.is_string() || v.is_number() || v.is_boolean()) {
438            continue;
439        }
440        let want = if required.contains(&key.as_str()) { schema_type(Some(prop)) } else { optional_type(schema_type(Some(prop))) };
441        let t = literal_type(v);
442        if !fits(&want, &t) {
443            issues.push(issue(here, format!("{key} is {}, got {}", type_display(&want), type_display(&t))));
444        }
445    }
446    for field in required {
447        if !given.contains_key(field) {
448            issues.push(issue(at, format!("{field} is required by action {name}")));
449        }
450    }
451}
452
453/// VAPP-204: a `bind` press's literal source, checked like a template
454/// binding: a scheme:name URI, in the package's `sources`, a declared kind
455/// and a package query.
456fn bind_source_issues(pkg: &Map<String, Value>, args: Option<&Value>, at: &str, issues: &mut Vec<PackageIssue>) {
457    let Some(source) = args.and_then(|a| a.get("source")).and_then(Value::as_str) else { return };
458    let here = format!("{at}/source");
459    let Some(parsed) = super::sources::parse_source(source) else {
460        issues.push(issue(here, "source is a scheme:name URI"));
461        return;
462    };
463    if let Some(list) = pkg.get("sources").and_then(Value::as_array).filter(|l| l.iter().all(Value::is_string)) {
464        let allowed: Vec<String> = list.iter().filter_map(Value::as_str).map(str::to_string).collect();
465        if !super::package::source_allowed(Some(&allowed), source) {
466            issues.push(issue(&here, format!("source {source} is not in the package's sources")));
467        }
468    }
469    if parsed.scheme == super::record_store::RECORDS_SCHEME {
470        if !pkg.get("records").and_then(Value::as_object).is_some_and(|r| r.contains_key(&parsed.name)) {
471            issues.push(issue(&here, format!("records:{} names no declared kind", parsed.name)));
472        }
473        if let Some(query) = parsed.params.get("query") {
474            if !pkg.get("queries").and_then(Value::as_object).is_some_and(|q| q.contains_key(query)) {
475                issues.push(issue(&here, format!("query {query} names no package query")));
476            }
477        }
478    }
479}
480
481// ---------------------------------------------------------------------------
482// Running (pure: the plan, or the one error)
483// ---------------------------------------------------------------------------
484
485fn issue_text(issues: &[PackageIssue]) -> String {
486    issues.iter().map(|i| format!("{} {}", if i.path.is_empty() { "/" } else { &i.path }, i.message)).collect::<Vec<_>>().join("; ")
487}
488
489/// Run a package action against one snapshot: its plan `{writes, sets,
490/// calls}`, or the error that discards it whole (a false `require` = its
491/// message; else `step <i>: …`).
492pub fn run_action(pkg: &Value, name: &str, params: &Value, ctx: &ActionContext) -> Result<Value, String> {
493    let action = pkg.get("actions").and_then(Value::as_object).and_then(|a| a.get(name)).and_then(Value::as_object);
494    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}")) };
495    let p = if params.is_null() { Value::Object(Map::new()) } else { params.clone() };
496    if !p.is_object() {
497        return Err("params is an object".into());
498    }
499    if let Some(schema) = action.get("params").filter(|s| s.is_object()) {
500        let issues = validate_record(schema, &p);
501        if !issues.is_empty() {
502            return Err(format!("params: {}", issue_text(&issues)));
503        }
504    }
505    let mut vars = Map::new();
506    vars.insert("params".into(), p);
507    vars.insert("data".into(), ctx.data.cloned().unwrap_or(Value::Null));
508    vars.insert("me".into(), ctx.me.map_or(Value::Null, |m| Value::String(m.into())));
509    let empty = Map::new();
510    let records = pkg.get("records").and_then(Value::as_object).unwrap_or(&empty);
511    let mut budget: i64 = ctx.max_steps.unwrap_or(ACTION_LIMIT_MAX_STEPS) as i64;
512    let mut writes = Vec::new();
513    let mut sets = Vec::new();
514    let mut calls = Vec::new();
515    for (i, step) in steps.iter().enumerate() {
516        let r#where = format!("step {i}");
517        let limit = || format!("{where}: action step limit exceeded");
518        budget -= 1;
519        if budget < 0 {
520            return Err(limit());
521        }
522        let mut value = |v: &Value| -> Result<Value, String> {
523            let Value::String(source) = v else { return Ok(v.clone()) };
524            let ast = parse_expression(source).map_err(|e| format!("{where}: {e}"))?;
525            let cap = (MAX_STEPS as i64).min(budget);
526            let env = ExpressionEnv { now: ctx.now, offset: ctx.offset, max_steps: Some(cap.max(0) as usize) };
527            let (result, used) = evaluate_counted(&ast, &vars, &env);
528            match result {
529                Err(e) if e == "step limit exceeded" && cap == budget => Err(limit()),
530                Err(e) => Err(format!("{where}: {e}")),
531                Ok(v) => {
532                    budget -= used as i64;
533                    if budget < 0 {
534                        return Err(limit());
535                    }
536                    Ok(v)
537                }
538            }
539        };
540        let (Some(kind), Some(step)) = (step_kind(step), step.as_object()) else {
541            return Err(format!("{where}: a step is one of {}", ACTION_STEP_KINDS.join(", ")));
542        };
543        let body = &step[kind];
544        match kind {
545            "require" => {
546                let ok = value(body)?;
547                let Value::Bool(ok) = ok else { return Err(format!("{where}: require needs a bool, got {}", type_name(&ok))) };
548                if !ok {
549                    return Err(match step.get("message").and_then(Value::as_str).filter(|m| !m.is_empty()) {
550                        Some(m) => m.to_string(),
551                        None => format!("{where}: requirement failed"),
552                    });
553                }
554            }
555            "records.upsert" => {
556                let Some((body, data_exprs)) = body.as_object().and_then(|b| b.get("data").and_then(Value::as_object).map(|d| (b, d))) else {
557                    return Err(format!("{where}: records.upsert is {{kind, data, id?}}"));
558                };
559                let kind_name = body.get("kind").and_then(Value::as_str).unwrap_or("");
560                let Some(schema) = records.get(kind_name).and_then(Value::as_object).and_then(|d| d.get("schema")).filter(|s| s.is_object()) else {
561                    return Err(format!("{where}: records: no kind {kind_name}"));
562                };
563                let mut id = None;
564                if let Some(expr) = body.get("id") {
565                    let v = value(expr)?;
566                    match v {
567                        Value::String(s) if !s.is_empty() => id = Some(s),
568                        other => return Err(format!("{where}: id is a string, got {}", type_name(&other))),
569                    }
570                }
571                let mut data = Map::new();
572                for field in sorted_keys(data_exprs) {
573                    data.insert(field.clone(), value(&data_exprs[field.as_str()])?);
574                }
575                let data = Value::Object(data);
576                // A patch is checked field by field (the store merges and checks the row).
577                let issues = if id.is_none() {
578                    validate_record(schema, &data)
579                } else {
580                    let mut patch = schema.clone();
581                    patch["required"] = json!([]);
582                    validate_record(&patch, &data)
583                };
584                if !issues.is_empty() {
585                    return Err(format!("{where}: records: {}", issue_text(&issues)));
586                }
587                writes.push(match id {
588                    None => json!({"op": "upsert", "kind": kind_name, "data": data}),
589                    Some(id) => json!({"op": "upsert", "kind": kind_name, "data": data, "id": id}),
590                });
591            }
592            "records.delete" => {
593                let Some(body) = body.as_object() else { return Err(format!("{where}: records.delete is {{id}}")) };
594                // A missing id evaluates like JS `undefined` (not a string).
595                let v = match body.get("id") {
596                    Some(expr) => value(expr)?,
597                    None => return Err(format!("{where}: id is a string, got null")),
598                };
599                match v {
600                    Value::String(s) if !s.is_empty() => writes.push(json!({"op": "delete", "id": s})),
601                    other => return Err(format!("{where}: id is a string, got {}", type_name(&other))),
602                }
603            }
604            "set" => {
605                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 {
606                    return Err(format!("{where}: path is a JSON pointer"));
607                };
608                let v = value(body.get("value").unwrap_or(&Value::Null))?;
609                sets.push(json!({"path": path, "value": v}));
610            }
611            "call" => {
612                let Some(name) = body.as_str().filter(|n| function_effect(n) == FunctionEffect::Host && action_call_name(n).is_none()) else {
613                    return Err(format!("{where}: call names a host function"));
614                };
615                let mut args = Map::new();
616                if let Some(given) = step.get("args").and_then(Value::as_object) {
617                    for k in sorted_keys(given) {
618                        args.insert(k.clone(), value(&given[k.as_str()])?);
619                    }
620                }
621                calls.push(json!({"name": name, "args": args}));
622            }
623            _ => {}
624        }
625    }
626    Ok(json!({"writes": writes, "sets": sets, "calls": calls}))
627}
628
629// ---------------------------------------------------------------------------
630// The batch (one unit over in-memory rows)
631// ---------------------------------------------------------------------------
632
633/// Apply a batch of writes (`{op: "upsert", kind, data, id?}` | `{op:
634/// "delete", id}`) to `rows` (`{id, kind, data, createdBy, createdAt,
635/// updatedAt}`), all or nothing: the new rows and each write's id, or the
636/// first refusal (`write <i>: …`). A patch merges onto its row and the
637/// merged row is checked; a create's id is `new_id(<i-th create>)`, its
638/// `createdBy` = `me`, every touched row stamped `now`.
639pub 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> {
640    apply_record_batch_as(rows, writes, records, me, None, None, now, new_id)
641}
642
643/// eApps R3: [`apply_record_batch`] with every write judged by its kind's
644/// policy for the writer `me` with team `role` and app `access` (TS
645/// `BatchOptions.role` / `access`); `role` `None` = not judged.
646#[allow(clippy::too_many_arguments)]
647pub fn apply_record_batch_as(
648    rows: &[Value],
649    writes: &[Value],
650    records: Option<&Value>,
651    me: Option<&str>,
652    role: Option<&str>,
653    access: Option<&str>,
654    now: &str,
655    mut new_id: impl FnMut(usize) -> String,
656) -> Result<(Vec<Value>, Vec<String>), String> {
657    use super::record_policy::{record_policy, record_refusal, RecordOp, RecordViewer};
658    let judged = role.is_some();
659    let viewer = RecordViewer { id: me, role, access };
660    let kind_def = |kind: &str| records.and_then(|r| r.get(kind));
661    let judge = |kind: &str, op: RecordOp, row: Option<&Value>| if judged { record_refusal(&record_policy(kind_def(kind)), op, &viewer, row) } else { None };
662    // A row the writer may not read is not there for them.
663    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();
664    let mut out: Vec<Value> = rows.to_vec();
665    let mut ids = Vec::new();
666    let mut creates = 0;
667    let row_id = |r: &Value| r.get("id").and_then(Value::as_str).map(str::to_string);
668    for (i, w) in writes.iter().enumerate() {
669        let r#where = format!("write {i}");
670        let wid = w.get("id").map(|v| v.as_str().map_or_else(|| v.to_string(), str::to_string));
671        if w.get("op").and_then(Value::as_str) == Some("delete") {
672            let id = wid.unwrap_or_else(|| "undefined".into());
673            let Some(row) = out.iter().find(|r| row_id(r).as_deref() == Some(id.as_str()) && readable(r)) else {
674                return Err(format!("{where}: record {id} not found"));
675            };
676            if let Some(refusal) = judge(row.get("kind").and_then(Value::as_str).unwrap_or(""), RecordOp::Delete, Some(row)) {
677                return Err(format!("{where}: {refusal}"));
678            }
679            out.retain(|r| row_id(r).as_deref() != Some(id.as_str()));
680            ids.push(id);
681            continue;
682        }
683        let kind = w.get("kind").and_then(Value::as_str).unwrap_or("undefined");
684        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 {
685            return Err(format!("{where}: records: no kind {kind}"));
686        };
687        let wdata = w.get("data").cloned().unwrap_or(Value::Null);
688        if let Some(id) = wid {
689            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")) };
690            if out[index].get("kind").and_then(Value::as_str) != Some(kind) {
691                return Err(format!("{where}: record {id} is not of kind {kind}"));
692            }
693            if let Some(refusal) = judge(kind, RecordOp::Update, Some(&out[index])) {
694                return Err(format!("{where}: {refusal}"));
695            }
696            let mut data = out[index].get("data").and_then(Value::as_object).cloned().unwrap_or_default();
697            if let Some(patch) = wdata.as_object() {
698                for (k, v) in patch {
699                    data.insert(k.clone(), v.clone());
700                }
701            }
702            let data = Value::Object(data);
703            let issues = validate_record(schema, &data);
704            if !issues.is_empty() {
705                return Err(format!("{where}: records: {}", issue_text(&issues)));
706            }
707            if let Some(row) = out[index].as_object_mut() {
708                row.insert("data".into(), data);
709                row.insert("updatedAt".into(), Value::String(now.into()));
710            }
711            ids.push(id);
712            continue;
713        }
714        if let Some(refusal) = judge(kind, RecordOp::Create, None) {
715            return Err(format!("{where}: {refusal}"));
716        }
717        let issues = validate_record(schema, &wdata);
718        if !issues.is_empty() {
719            return Err(format!("{where}: records: {}", issue_text(&issues)));
720        }
721        let id = new_id(creates);
722        creates += 1;
723        out.push(json!({"id": id, "kind": kind, "data": wdata, "createdBy": me, "authorId": me, "createdAt": now, "updatedAt": now}));
724        ids.push(id);
725    }
726    Ok((out, ids))
727}