0006 — Host functions with a JSON boundary
Status: Accepted; extended by ADR 0011, which lets host functions call the functions they receive
Context
jsonata-js lets applications pass their own functions in the bindings of an evaluation, or
register them on an expression with registerFunction. Rule engines use this to reach data and
services the document does not hold: rate tables, currency conversion, lookups. jlens had no
equivalent, because Bindings held only JSON values.
A host function has to fit four constraints:
ExpressionisSend + Sync, andBindingsis too. Applications share both between threads.- The run-time
Value(ADR 0003) is internal. It is built onRc, so it is notSend. It also carries the sequence flags of jsonata-js, which a host should not have to know about. - Evaluations never mutate their input, and a
Documentis shared between evaluations. - Without a tracer, the evaluator must not get slower (ADR 0004).
Options considered for the values that cross the boundary:
- Expose
Value, with accessors. This is the fastest option, with no conversion, and callbacks are possible. But it makes the internal model, its flags and itsRcs a public API. - A new public value type. It needs a conversion anyway, and adds a third value model next
to
serde_json::Valueand the internal one. serde_json::Value, withNonefor undefined. This is the type of documents, results and variable bindings already, and every Rust JSON tool works with it.
Decision
- The boundary is JSON. A
HostFunctionwraps anArc<dyn Fn(&[Option<JsonValue>], &HostCall) -> Result<Option<JsonValue>> + Send + Sync>. Arguments are converted with the same rules as results (JSON.stringify): a function argument arrives asNone. The context value is converted only when the function asks for it (HostCall::input), since it can be the whole document. - Optional signatures reuse the parser and validator of the built-in functions. A validated
call behaves like a call to a built-in: same type errors, same context substitution. A
function without a signature declares its arity, which plays the part of a JavaScript
function’s
lengthfor higher-order functions and partial application. - Bindings hold values and functions. A name holds one or the other: binding one replaces the other. Each evaluation binds the host functions in its root frame next to the variables. So they take precedence over built-in functions of the same name, as in jsonata-js.
- Function values gain a
Hostvariant. Partial application of built-in functions is generalized to host functions:Partialholds the target function. - Errors are
jlens_core::Errors with a code chosen by the host. As for any error, the call site fills in the position and token when the error has none.
Consequences
BindingsandHostFunctionareSend + Sync. In WebAssembly, JavaScript functions are notSend, so the bindings refer to them indirectly, through a registry local to the evaluation.- In WebAssembly,
thisisundefined, where jsonata-js passes the context value. Passing it would convert the context value to JavaScript at every call, which is the whole document for a call at the top of the expression. A signature with-passes it as an argument instead. An exception thrown by a JavaScript function is kept, and the evaluation rethrows it as is. A promise is refused (U1002), since evaluation is synchronous. - Each call copies its arguments and its result. For the functions that rule engines call, which take a few scalars, the cost is small. It is large only for a function that receives a large array or object.
- A host function cannot call a JSONata function it receives as an argument. A later decision can
add a callable handle, or a public value type, without changing the functions written against
this API. ADR 0011 added that handle (
HostCall::function) in 0.13.0. - The evaluator’s hot path only gains match arms. Tracing needs no new event: a host call is a
function call node, with its
enterandexitevents. - Host functions run to completion. The evaluation’s timeout is checked again once they return.