Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Node.js (Native Binding)

@goplasmatic/datalogic-node is the native Node.js binding: the Rust core compiled per platform and loaded through napi-rs, with no WebAssembly in between. On Node servers it is the fast path, running close to native Rust throughput.

Two npm packages, one engine. This package is for Node services that want maximum throughput. @goplasmatic/datalogic-wasm is the WebAssembly build: it also runs under Node, but its home turf is browsers, edge runtimes, Deno, and Bun. Same core, same semantics, same conformance battery either way.

Install

npm install @goplasmatic/datalogic-node

Prebuilt platform binaries are published as optionalDependencies, so npm pulls only the .node file matching your platform:

PlatformArchitectures
Linux (glibc)x64, arm64
Linux (musl)x64, arm64
macOSx64, arm64
Windowsx64, arm64

Node 18 and newer are supported. There is no build step and no WASM initialization: import and call.

Quick start

Rules and data are plain JavaScript objects; results come back as JavaScript values:

import { apply } from '@goplasmatic/datalogic-node';

const result = apply(
  { if: [{ '>': [{ var: 'score' }, 50] }, 'pass', 'fail'] },
  { score: 75 }
);
// -> "pass"

Both arguments also accept JSON text: a JS string passed as rule or data is parsed as JSON, not treated as a string value. To evaluate against a data document that is a JSON string, pass it encoded (rule.evaluate(JSON.stringify('hello'))), or hand the JSON text to the string-in methods (evaluateStr) or a DataHandle.

Compile once, evaluate many

For repeated evaluations of the same rule, compile once and hold the Rule instance:

import { Engine } from '@goplasmatic/datalogic-node';

const engine = new Engine();
const rule = engine.compile({ '+': [{ var: 'x' }, 1] });

for (const payload of inputs) {
  console.log(rule.evaluate(payload));
}

Rule has no thread affinity on the Rust side, but napi class instances cannot be posted or transferred between worker_threads: a Rule sent through postMessage arrives as an empty plain object with no evaluate. Each worker must load the module and compile its own Rule (compiling is cheap). For parallelism from a single thread, rule.evaluateStrAsync(json) evaluates on the libuv pool (see Async evaluation).

The constructor takes an options bag and, separately, a map of custom operators:

const strict = new Engine({ config: { preset: 'strict' } });
const templated = new Engine({ templating: true });
const custom = new Engine({}, {
  double: (argsJson) => String(JSON.parse(argsJson)[0] * 2),
});
custom.compile({ double: [21] }).evaluate({}); // 42

Note the nesting: preset and the other evaluation options go under config, not at the top level. The options bag only reads templating, templateKeyEscape and config; other top-level keys are ignored, so new Engine({ preset: 'strict' }) silently builds a default engine.

templateKeyEscape is a single-character prefix, unset by default, that lets a template emit a key which would otherwise be swallowed as an operator: with new Engine({ templating: true, templateKeyEscape: '$' }), { $type: { var: 'x' } } yields { type: 1 } rather than running the type operator, and { $$type: 1 } yields { $type: 1 }. Anything other than a one-character string throws errorType: 'InvalidArguments'. See Structured Objects. Unknown keys inside config throw errorType: 'ConfigurationError'. Configuration covers what each option means.

Sessions: hot-loop arena reuse

A Session reuses one bump arena across evaluations and resets between calls to bound peak memory. Open one per worker thread:

const sess = engine.session();
for (const payload of inputs) {
  sess.evaluate(rule, payload);
}

Sessions hold non-Sync state and must not be shared between worker threads.

Data handles and typed results

A DataHandle is an immutable, pre-parsed JSON document. Parse a payload once and every evaluation against it skips JSON parsing entirely; the typed session methods also skip the result round trip:

import { DataHandle } from '@goplasmatic/datalogic-node';

const handle = new DataHandle('{"age": 25, "status": "active"}'); // throws ParseError on bad JSON
const isAdult = engine.compile({ '>=': [{ var: 'age' }, 18] });
const nextAge = engine.compile({ '+': [{ var: 'age' }, 1] });

nextAge.evaluateData(handle);         // 26   (JS value out, no parse per call)
nextAge.evaluateDataStr(handle);      // "26" (JSON string out)
sess.evaluateData(isAdult, handle);   // true (hot path: session arena + no parse)
sess.evaluateBool(isAdult, handle);   // true (strict JSON boolean, else TypeMismatch)
sess.evaluateNumber(nextAge, handle); // 26   (any JSON number, else TypeMismatch)
sess.evaluateTruthy(isAdult, handle); // true (JSONLogic truthiness, never mismatches)

evaluateBool and evaluateNumber throw an EvaluateError with errorType: 'TypeMismatch' when the rule evaluated fine but the result has another type. Handles are per-thread like sessions: parse one per worker.

Batch evaluation

The batch entry points evaluate a whole set in one native call and report outcomes per item in the Promise.allSettled shape, so one bad input never poisons its neighbours:

const outcomes = sess.evaluateBatch(rule, [h0, h1, h2]); // one rule, many payloads
const flags = sess.evaluateMany([r0, r1], handle);        // many rules, one payload

for (const [i, o] of flags.entries()) {
  if (o.status === 'rejected') {
    console.log(`rule ${i} failed: ${o.reason.message} (${o.reason.tag})`);
  } else {
    console.log(`rule ${i}: ${o.value}`); // result as a JSON string
  }
}

Async evaluation

rule.evaluateStrAsync(dataJson) evaluates on the libuv thread pool and returns a Promise<string>. It is not faster per call than evaluateStr; the win is keeping large payloads’ parse + evaluate + serialize off the event loop:

const result = await rule.evaluateStrAsync('{"age": 25}');

String input only (a DataHandle is pinned to the JS thread). Rejections carry the same structured fields as synchronous throws.

Tracing

engine.evaluateWithTrace(logic, data) returns a JSON string with the same { result, expression_tree, steps, error?, structured_error? } envelope the WASM package produces, so the React debugger accepts output from either package:

const run = JSON.parse(engine.evaluateWithTrace('{"+": [1, 2, 3]}', 'null'));
run.result;       // 6
run.steps.length; // 1 (one step per operator node; literals record no step)

Failures do not throw: result is null, error carries the message, and structured_error the structured form. The rule is compiled with optimization disabled so every operator surfaces a step; use it for debugging, not hot paths.

Operator names

Tooling that validates or autocompletes rules can ask the binding for its vocabulary instead of keeping a hand-maintained list:

import { builtinOperatorNames, Engine } from '@goplasmatic/datalogic-node';

const names = builtinOperatorNames();
names.length;               // 67: the 64 built-in operators plus the aliases var, ?:, match
names.includes('group_by'); // true

const engine = new Engine({}, { double: (a) => String(JSON.parse(a)[0] * 2) });
engine.customOperatorNames(); // ['double']

builtinOperatorNames() is derived from the compiler’s own lookup table, so it cannot drift from dispatch; engine.customOperatorNames() lists the operators registered on that engine. The union is the engine’s full vocabulary.

Error handling

Failures throw plain JS Error instances with structured fields attached:

try {
  rule.evaluate(data);
} catch (e) {
  if (e.name === 'ParseError') {
    // Malformed rule or data JSON
  } else if (e.name === 'EvaluateError') {
    console.log(e.errorType);  // stable tag (e.g. "InvalidOperator", "Thrown")
    console.log(e.operator);   // outermost failing operator
    console.log(e.nodeIds);    // leaf-to-root breadcrumb
    console.log(e.path);       // resolved root-to-leaf step list
  }
}

compile rejects malformed JSON and structurally invalid rules (a multi-key object when templating is off), but it does not verify operator names: an unknown operator compiles and surfaces as errorType: 'InvalidOperator' when the rule is evaluated. Missing data is not an error: { var: 'missing' } evaluates to null.

API surface

SymbolDescription
apply(rule, data)One-shot compile + evaluate
builtinOperatorNames()Every built-in operator name this build accepts (includes aliases)
new Engine({ templating?, templateKeyEscape?, config? }, customOperators?)Engine with optional templating, template-key escape, evaluation config, and custom operators
engine.compile(rule)Compile to a reusable Rule
engine.eval(rule, data) / engine.evalStr(rule, data)One-shot, JS value / JSON string out
engine.evaluateWithTrace(logic, data)One-shot with execution trace (JSON strings in and out)
engine.session()Arena-reusing Session for hot loops
engine.customOperatorNames()Names of the custom operators registered on this engine
new DataHandle(json) / handle.allocatedBytesParse a payload once; arena bytes held by the handle
rule.evaluate(data) / rule.evaluateStr(data)Evaluate against one payload, JS value / JSON string out
rule.evaluateData(handle) / rule.evaluateDataStr(handle)Evaluate a pre-parsed handle
rule.evaluateStrAsync(dataJson)Evaluate on the libuv pool, returns Promise<string>
sess.evaluate(rule, data) / sess.evaluateStr(rule, data)Evaluate with arena reuse
sess.evaluateData(rule, handle) / sess.evaluateDataStr(rule, handle)Handle in, arena reuse; the Str form is the fastest path
sess.evaluateBool / evaluateNumber / evaluateTruthy (rule, handle)Typed results (TypeMismatch for the strict two)
sess.evaluateBatch(rule, handles) / sess.evaluateMany(rules, handle)Batch evaluation, allSettled-style items
sess.reset() / sess.allocatedBytes()Explicit arena reset (optional); bytes currently held by the arena’s chunks

The package README carries the same surface with the configuration key table; Configuration covers what each option means.

When to choose WASM instead

Choose @goplasmatic/datalogic-wasm when the code must run in a browser or edge runtime, or when you want one artifact across Node + browser. Choose this native package for Node services where throughput matters: the WASM build measures roughly 88× slower than the native core on the same benchmark workload.

Next steps