1use 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
24pub const ACTION_LIMIT_ACTIONS: usize = 32;
27pub const ACTION_LIMIT_STEPS: usize = 64;
28pub const ACTION_LIMIT_MAX_STEPS: usize = 100_000;
29pub const ACTION_STEP_KINDS: &[&str] = &["require", "records.upsert", "records.delete", "set", "call"];
31pub const ACTION_NAME_PATTERN: &str = "^[a-z][A-Za-z0-9_]{0,31}$";
33pub const ACTION_CALL_PREFIX: &str = "actions.";
35pub const RECORDS_BATCH_FUNCTION: &str = "records.batch";
37
38#[derive(Default, Clone, Copy)]
40pub struct ActionContext<'a> {
41 pub data: Option<&'a Value>,
43 pub me: Option<&'a str>,
45 pub now: Option<f64>,
47 pub offset: Option<&'a (dyn Fn(f64) -> i32 + Send + Sync)>,
49 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
65fn 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
82pub 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
88pub fn action_call_name(call: &str) -> Option<&str> {
90 call.strip_prefix(ACTION_CALL_PREFIX)
91}
92
93fn 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
110pub 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 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
128fn fits(field: &Value, t: &Value) -> bool {
130 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
146pub 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
200fn 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 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 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
361pub 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 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 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
417fn 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
458fn 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
486fn 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
494fn 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
509pub 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 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 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 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 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 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
672pub 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#[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 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}