Migrating to datalogic-rs v5
July 3, 2026 · View on GitHub
v5 is a clean break from v4. There is no compat feature, no
LegacyApi trait, and no deprecated method shims inside the v5 crate —
v4 callers rewrite their call sites following this guide. The
rewrites are mechanical and 1:1; this document is the authoritative
cookbook.
If you are still on v4 and not ready to migrate, stay on the latest 4.x release. v5 does not support a side-by-side mode.
5.0.0 → 5.0.1: C ABI v2 (bindings-internal)
5.0.1 replaces the C ABI (bindings/c) wholesale — ABI v2. If you use
any language binding through its package manager (npm, PyPI, NuGet, Go
modules, Maven, Packagist), nothing changes for you: the wrapper
APIs are source-compatible and only gain new surface (parse-once data
handles, batch evaluation, typed results). Two environmental notes:
- JVM: the binding now runs on
java.lang.foreign(JDK 22+ required; the JNA dependency is gone). On JDK 24+ add--enable-native-access=ALL-UNNAMEDto permit restricted native access without warnings. - PHP: a preloadable FFI header is now available for
opcache.preload+ffi.enable=preloaddeployments;FFI::cdefremains the zero-config default.
If you built directly against bindings/c (in-tree only, never
published as a standalone artifact), the v1 symbols are gone. Assert
datalogic_abi_version() == 2 at load, then migrate:
| v1 | v2 |
|---|---|
NUL-terminated char* inputs | (const uint8_t *, size_t) UTF-8 byte ranges |
char* returns + datalogic_string_free | sessions return borrowed (ptr, len) valid until the next call on that session; one-shots fill an owned datalogic_buf, released via datalogic_buf_free |
NULL return + thread-local datalogic_last_error_* block | datalogic_status return + optional datalogic_error ** out-param; read datalogic_error_status/_message/_tag/_operator/_path_json, then datalogic_error_free |
datalogic_last_error_clear | deleted — there is no thread-local state |
operator callback returns a malloc'd string the binding frees | callback writes via datalogic_op_result_set_json / _set_error and returns 0/non-zero — no allocator crosses the boundary |
| — | new surface: datalogic_abi_version, datalogic_data_parse/_free/_allocated_bytes, datalogic_rule_evaluate_data, datalogic_session_evaluate_data/_bool/_i64/_f64/_truthy/_batch/_many |
Full contract: bindings/c/README.md and the
generated bindings/c/include/datalogic.h.
v4 → v5 in 60 seconds
Most call-site changes are mechanical 1:1 renames. The deep-dive is below; this checklist covers the 90% case.
Rust callers
- Cargo feature:
compat→serde_json(details) - Engine construction is builder-only:
Engine::with_config(c)→Engine::builder().with_config(c).build()(details) - Templating:
with_preserve_structure()→builder().with_templating(true).build()(details) - One-shot:
engine.evaluate_json(rule, data) -> Value→engine.eval_str(rule, data) -> String(JSON in/out) orengine.eval_into::<T>(rule, data)(typed) (details) - Compile from
&Value:engine.compile_serde_value(&v)→engine.compile(&v)(details) - Custom operators:
ArenaOperator→CustomOperator,&mut ContextStack→&mut EvalContext(details) - Trace:
engine.evaluate_json_with_trace(...)→engine.trace().eval_str(...)returningTracedRun<R>(details)
JS / TS callers
- npm install:
@goplasmatic/datalogic→@goplasmatic/datalogic-wasm(browser/edge) or@goplasmatic/datalogic-node(new in v5, Node-native via napi-rs) (details) - Templating flag rename:
preserve_structure→templating— same semantics (details)
If a v4 surface isn't covered here, search this document or file an issue.
Contents
- 5.0.0 → 5.0.1: C ABI v2 (bindings-internal)
- npm package rename (JS/TS consumers only)
- What changed at a glance
- Cargo.toml
- Method-by-method translation
- New v5 capabilities (not in v4)
- Common patterns side-by-side
- Recipe: structural-error consumers
- JavaScript / npm consumers
- Things that did NOT change
- If you get stuck
npm package rename (JS/TS consumers only)
The WASM npm package was renamed to align with the datalogic-<lang>
convention used by every other binding:
| v4 | v5 |
|---|---|
@goplasmatic/datalogic (WASM) | @goplasmatic/datalogic-wasm |
| (new in v5) | @goplasmatic/datalogic-node — native Node.js binding via napi-rs |
@goplasmatic/datalogic-ui | @goplasmatic/datalogic-ui (unchanged) |
If you are a JS consumer:
- Browsers, Deno, Bun, Cloudflare Workers → switch
npm install @goplasmatic/datalogictonpm install @goplasmatic/datalogic-wasm. - Node.js services → install
@goplasmatic/datalogic-nodeinstead; it's the new native build (per-platform.nodeprebuild) and is materially faster than the WASM path for Node. The WASM package still works under Node if you'd rather have a single artifact across Node + browser. - React UI consumers → no change.
@goplasmatic/datalogic-uibundles its own WASM internally.
The legacy @goplasmatic/datalogic name is not republished at v5; v4.x
versions remain installable for consumers not ready to move.
What changed at a glance
| Concern | v4 | v5 |
|---|---|---|
| Feature flag for serde_json | compat | serde_json |
| One-shot evaluation | engine.evaluate_json(rule, data) -> Value | engine.eval_str(rule, data) -> String or eval_into::<T> |
| Value-boundary evaluation | engine.evaluate_owned(&logic, value) -> Value | engine.eval_into::<serde_json::Value, _, _>(rule, &value) -> Value |
Compile from &Value | engine.compile_serde_value(&value) | engine.compile(&value) (via IntoLogic, requires serde_json) |
| Construct with config | Engine::with_config(cfg) | Engine::builder().with_config(cfg).build() |
| Templating | Engine::with_preserve_structure() | Engine::builder().with_templating(true).build() |
| Trace one-shot | engine.evaluate_json_with_trace(rule, data) | engine.trace().eval_str(rule, data) -> TracedRun<String> |
| Custom-op context type | &mut ContextStack<'a> | &mut EvalContext<'_, 'a> |
Arc<Logic> shortcut | (manual Arc::new(...)) | engine.compile_arc(rule) -> Arc<Logic> |
| Module-level conveniences | none | datalogic::eval / eval_str / eval_into / compile |
Cargo.toml
Rename the feature you depend on:
# v4
[dependencies]
datalogic-rs = { version = "4", features = ["compat"] }
# v5
[dependencies]
datalogic-rs = { version = "5", features = ["serde_json"] }
If you only used the JSONLogic baseline (no serde_json::Value,
no typed serde input/output), drop the feature entirely:
datalogic-rs = "5"
The v4 wasm feature (JS-host clock for now when targeting
wasm32-unknown-unknown) is called wasm-clock in v5, and the now
operator itself now also needs datetime:
# v4
datalogic-rs = { version = "4", features = ["wasm"] }
# v5
datalogic-rs = { version = "5", features = ["datetime", "wasm-clock"] }
As in v4, leave it off when the module runs in a non-JS wasm runtime (wasmtime, wazero, Chicory): the JS clock imports would fail to instantiate there (#47).
Method-by-method translation
Engine construction
| v4 | v5 |
|---|---|
Engine::new() | Engine::new() (unchanged) |
Engine::with_config(cfg) | Engine::builder().with_config(cfg).build() |
Engine::with_preserve_structure() | Engine::builder().with_templating(true).build() |
Engine::with_config_and_structure(c, s) | Engine::builder().with_config(c).with_templating(s).build() |
Compilation
| v4 | v5 |
|---|---|
engine.compile(&Value) | engine.compile(&value) (IntoLogic accepts the same shape) |
engine.compile_serde_value(&Value) | engine.compile(&value) (collapsed) |
Arc::new(engine.compile(...)?) | engine.compile_arc(...)? |
compile now accepts &str, &String, &OwnedDataValue,
OwnedDataValue, and &serde_json::Value. A typed &T: Serialize
input goes via serde_json::to_value(&t)? first.
Evaluation
| v4 | v5 |
|---|---|
engine.evaluate(&logic, Arc<Value>) → Value | engine.session().eval_into::<serde_json::Value, _, _>(&compiled, &*arc) (compiled logic evaluates through a session) |
engine.evaluate_owned(&logic, value) | let v: serde_json::Value = engine.session().eval_into(&compiled, &value)? |
engine.evaluate_json(rule_str, data_str) → Value | let v: serde_json::Value = engine.eval_into(rule_str, data_str)? or engine.eval_str(...) for String result |
The v5 eval_into::<T> has three generic parameters (T, R, D).
You can either annotate the binding (let v: T = ...) and let
inference fill in R/D, or use turbofish placeholders:
engine.eval_into::<T, _, _>(rule, data).
Trace
| v4 | v5 |
|---|---|
engine.evaluate_json_with_trace(rule, data) -> TracedResult | engine.trace().eval_str(rule, data) -> TracedRun<String> — the outer Result collapses into TracedRun.result |
TracedResult is gone. TracedRun<R> shape:
pub struct TracedRun<R> {
pub result: Result<R, Error>, // success and failure share one field
pub steps: Vec<ExecutionStep>,
pub expression_tree: ExpressionNode,
}
Migration of field accesses:
// v4
let result = engine.evaluate_json_with_trace(rule, data).unwrap();
assert!(result.error.is_none());
assert_eq!(result.result, json!(true));
// v5 — string result
let run = engine.trace().eval_str(rule, data);
assert_eq!(run.result.unwrap(), "true");
// v5 — typed value result
let run: TracedRun<serde_json::Value> = engine.trace().eval_into(rule, data);
assert_eq!(run.result.unwrap(), json!(true));
// v5 — error case
let run = engine.trace().eval_str(bad_rule, data);
let err = run.result.unwrap_err();
assert_eq!(err.operator(), Some("throw"));
Custom operators
The trait stays — the method body is unchanged; only the context parameter type changes:
// v4
use datalogic_rs::compat::{ArenaOperator, ArenaContextStack};
impl ArenaOperator for Double {
fn evaluate_arena<'a>(
&self,
args: &[&'a DataValue<'a>],
_ctx: &mut ArenaContextStack<'a>,
arena: &'a Bump,
) -> Result<&'a DataValue<'a>> {
let n = args[0].as_f64().unwrap_or(0.0);
Ok(arena.alloc(DataValue::from_f64(n * 2.0)))
}
}
// v5
use datalogic_rs::{CustomOperator, operator::EvalContext};
impl CustomOperator for Double {
fn evaluate<'a>(
&self,
args: &[&'a DataValue<'a>],
_ctx: &mut EvalContext<'_, 'a>,
arena: &'a Bump,
) -> Result<&'a DataValue<'a>> {
let n = args[0].as_f64().unwrap_or(0.0);
Ok(arena.alloc(DataValue::from_f64(n * 2.0)))
}
}
EvalContext exposes the same observations that were public on
ContextStack (root_input, depth). Code that reached into
ContextStack's private internals was already unsupported and has no
v5 path — open an issue if you have a use case.
Registration is unchanged: Engine::builder().add_operator("double", Double).build().
New v5 capabilities (not in v4)
You don't need these to migrate, but they're worth knowing:
- Module-level helpers.
datalogic::eval,eval_str,eval_into,compile— backed by a default engine, no construction required. - Owned and typed result paths.
engine.eval(...) -> OwnedDataValuefor raw owned,engine.eval_into::<MyStruct, _, _>(...)for typed. compile_arcfor the cross-thread sharing pattern.with_constant_folding(false)on the builder for callers that walk the compiled tree (debuggers, alternate evaluators).TracedSessionmirrorsSession1:1 —eval,eval_str,eval_into,eval_borrowedall returnTracedRun<R>.
Common patterns side-by-side
One-shot evaluation
// v4
let engine = Engine::new();
let result: serde_json::Value = engine.evaluate_json(rule, data)?;
// v5 — JSON in/out
let result: String = datalogic_rs::eval_str(rule, data)?;
// v5 — typed in/out
#[derive(Deserialize)]
struct Decision { passed: bool }
let result: Decision = datalogic_rs::eval_into(rule, data)?;
Compile once, evaluate many
// v4
let engine = Engine::new();
let compiled = engine.compile(&rule_value)?; // Arc<Logic>
for record in stream {
let r = engine.evaluate_owned(&compiled, record)?;
// ...
}
// v5
let engine = Engine::new();
let compiled = engine.compile(rule_str)?; // Logic
let mut session = engine.session();
for record in stream {
let r: serde_json::Value =
session.eval_into(&compiled, &record)?;
session.reset();
// ...
}
Cross-thread sharing
// v4
let engine = Engine::new();
let compiled = engine.compile(&rule)?; // already Arc<Logic>
let c2 = Arc::clone(&compiled);
std::thread::spawn(move || { /* use c2 */ });
// v5
let engine = Engine::new();
let compiled = engine.compile_arc(rule_str)?; // Arc<Logic>
let c2 = Arc::clone(&compiled);
std::thread::spawn(move || { /* use c2 */ });
Hot loop with zero-copy results
// v5 only — v4 had no exposed arena tier
use bumpalo::Bump;
let engine = Engine::new();
let compiled = engine.compile(rule_str)?;
let mut arena = Bump::new();
for input in stream {
let result = engine.evaluate(&compiled, input, &arena)?;
// ... use `result: &DataValue<'_>` while `arena` is alive
arena.reset();
}
Recipe: structural-error consumers
Error already carried operator/path in late 4.x; v5 makes that
the only path:
// v5
match engine.eval_str(rule, data) {
Ok(json) => println!("ok: {json}"),
Err(e) => {
eprintln!("op: {:?}", e.operator());
eprintln!("kind: {:?}", &e.kind);
// For a JSONLogic-style path of node ids:
eprintln!("node ids (leaf→root): {:?}", e.node_ids());
// Resolve to structured PathSteps (root→leaf):
if let Ok(compiled) = engine.compile(rule) {
eprintln!("path: {:#?}", e.resolve_path(&compiled));
}
}
}
JavaScript / npm consumers
The @goplasmatic/datalogic-wasm (WASM) and @goplasmatic/datalogic-ui
(React) packages share the v5 cutover. Two surface renames mirror the
Rust core:
| v4 JS surface | v5 JS surface |
|---|---|
evaluate(logic, data, preserve_structure) | evaluate(logic, data, templating) |
new CompiledRule(logic, preserve_structure) | new CompiledRule(logic, templating) |
<DataLogicEditor preserveStructure={…} /> | <DataLogicEditor templating={…} /> |
onPreserveStructureChange={…} | onTemplatingChange={…} |
The semantic of the flag is unchanged — true enables templating mode
where multi-key objects compile to output-shaping templates with
embedded JSONLogic.
Things that did NOT change
- Operator semantics (every JSONLogic operator behaves the same).
EvaluationConfigfield set, defaults, presets (safe_arithmetic,strict).DataValue/OwnedDataValueshape and accessors.Error::kind()variants, error-recovery behaviour, structured error fields.PathStepshape.- The two-phase compile/evaluate model and arena-allocated dispatch.
If you get stuck
- The renames are 1:1 — search-and-replace covers ~90% of call sites.
- For typed
eval_into, prefer annotating the result binding (let result: MyType = engine.eval_into(rule, data)?) over turbofishing all three generic parameters. - If a v4 method shape has no listed translation, file an issue — we'll add it here.