npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@divmain/jdm-asm

v1.1.3

Published

JDM to AssemblyScript WASM Compiler - Transforms JSON Decision Models into efficient WebAssembly

Readme

jdm-asm

A high-performance JDM (JSON Decision Model) compiler that transforms decision models into WebAssembly modules using AssemblyScript. Compatible with GoRules zen-engine JDM format, with single-digit microsecond single-decision latency and, for small-to-moderate decision tables, roughly 5-10x faster single-threaded execution than zen-engine in the benchmarks below. (Multi-threaded/IPC throughput depends on the available worker budget and can be lower than in-process zen-engine for cheap decisions; see Performance.)

Table of Contents

Overview

jdm-asm compiles JSON Decision Models (JDM) into optimized WebAssembly modules. This approach moves expensive parsing and interpretation to compile-time, resulting in significantly faster runtime execution compared to traditional JavaScript rule engines.

Key Features:

  • Ultra-Low Latency: ~3-7 microsecond execution for typical decisions in single-threaded mode
  • High Single-Threaded Performance: roughly 5-10x faster than zen-engine for small-to-moderate decision tables (single-threaded; see measured numbers and caveats in Performance)
  • Type Safety: TypeBox schemas for input/output validation
  • zen-engine Compatible: Drop-in replacement for most JDM files
  • Extended Hit Policies: Supports DMN-standard policies (unique, ruleOrder, outputOrder, priority)
  • Parallel-Ready: Compiled decisions are self-contained, so you can create one instance per worker thread for high-throughput batch processing

Installation

npm install @divmain/jdm-asm

Requirements:

  • Node.js 20+
  • TypeScript 5.0+ (for TypeBox schemas)

Quick Start

import { Type } from '@sinclair/typebox';
import { compile, createCompiledDecision } from '@divmain/jdm-asm';

// 1. Define input/output schemas
const InputSchema = Type.Object({
  customerType: Type.String(),
  orderAmount: Type.Number(),
});

const OutputSchema = Type.Object({
  discount: Type.Number(),
});

// 2. Define your decision model (or load from file)
const jdm = {
  nodes: [
    { id: 'input', type: 'inputNode', name: 'Request', position: { x: 0, y: 0 } },
    {
      id: 'table',
      type: 'decisionTableNode',
      name: 'Discount Rules',
      position: { x: 200, y: 0 },
      content: {
        hitPolicy: 'first',
        inputs: [
          { id: 'in1', name: 'Customer', field: 'customerType' },
          { id: 'in2', name: 'Amount', field: 'orderAmount' },
        ],
        outputs: [{ id: 'out1', name: 'Discount', field: 'discount' }],
        rules: [
          { _id: 'r1', in1: '"premium"', in2: '> 100', out1: '0.15' },
          { _id: 'r2', in1: '"standard"', in2: '> 100', out1: '0.10' },
          { _id: 'r3', in1: '', in2: '', out1: '0' },
        ],
      },
    },
    { id: 'output', type: 'outputNode', name: 'Response', position: { x: 400, y: 0 } },
  ],
  edges: [
    { id: 'e1', sourceId: 'input', targetId: 'table', type: 'edge' },
    { id: 'e2', sourceId: 'table', targetId: 'output', type: 'edge' },
  ],
};

// 3. Compile to WebAssembly
const compiled = await compile({
  jdm,
  inputSchema: InputSchema,
  outputSchema: OutputSchema,
});

// 4. Create decision instance
const decision = await createCompiledDecision(compiled);

// 5. Evaluate
const result = await decision.evaluate({
  customerType: 'premium',
  orderAmount: 150,
});

console.log(result); // { discount: 0.15 }

// 6. Clean up when done
decision.dispose();

Usage Guide

Defining Schemas

jdm-asm uses TypeBox schemas to define the structure of input and output data. Schemas enable:

  • Compile-time type checking
  • Runtime input validation
  • Optimized memory layout for WASM
import { Type } from '@sinclair/typebox';

// Simple flat schema
const InputSchema = Type.Object({
  age: Type.Number(),
  name: Type.String(),
  active: Type.Boolean(),
});

// Nested schema
const InputSchema = Type.Object({
  customer: Type.Object({
    id: Type.String(),
    tier: Type.String(),
    loyaltyYears: Type.Number(),
  }),
  order: Type.Object({
    total: Type.Number(),
    items: Type.Array(Type.Object({
      sku: Type.String(),
      price: Type.Number(),
      quantity: Type.Number(),
    })),
  }),
});

// Output schema
const OutputSchema = Type.Object({
  eligible: Type.Boolean(),
  discount: Type.Number(),
  message: Type.String(),
});

Creating JDM Files

JDM files are JSON documents containing nodes and edges that form a decision graph:

{
  "nodes": [
    {
      "id": "unique-node-id",
      "type": "inputNode|outputNode|expressionNode|decisionTableNode|switchNode|decisionNode",
      "name": "Human-readable name",
      "position": { "x": 0, "y": 0 },
      "content": { }
    }
  ],
  "edges": [
    {
      "id": "unique-edge-id",
      "type": "edge",
      "sourceId": "source-node-id",
      "targetId": "target-node-id"
    }
  ]
}

Every JDM must have:

  • Exactly one inputNode (entry point)
  • At least one outputNode (exit point)
  • A connected path from input to output

Compiling Decisions

The compile() function transforms JDM into WebAssembly:

import { compile } from '@divmain/jdm-asm';

const result = await compile({
  // Required
  jdm: jdmObject,           // JDM as object or JSON string
  inputSchema: InputSchema,  // TypeBox schema
  outputSchema: OutputSchema,

  // Optional
  optimize: true,            // Enable WASM optimizations (default: true)
  debug: false,              // Include AS source and WAT in output
  noMatchBehavior: {         // When no rules match:
    type: 'returnNull'       // 'returnNull' | 'throwError' | 'returnDefault'
  },
  loadDecision: (key) => {}, // Custom loader for sub-decisions
});

⚠️ Security: sub-decision keys are model-controlled path components. The key passed to your loadDecision originates from the decision model (decisionNode.content.key), not from the library. jdm-asm performs no filesystem access of its own for sub-decisions — it simply forwards the key to your loader. If your loader resolves keys against the filesystem, treat each key as an untrusted path component: a key such as ../../etc/passwd will traverse out of your intended directory if you naively path.join(root, key). Confine resolution to a fixed root and reject traversal — see Decision Node.

Compilation Result:

interface CompilationResult {
  wasm: Uint8Array;                // Compiled WASM binary
  schemaHash: bigint;              // Hash of the input/output schemas (schema identity only)
  marshalCode: string;             // JS code for data marshaling (embeds SCHEMA_HASH)
  validationCode: string;          // JS code for input validation
  outputValidationCode?: string;   // JS code for output validation (optional)
  wat?: string;                    // WAT text format (debug mode; see note below)
  watUnavailable?: boolean;        // WAT skipped for a large module (debug mode)
  assemblyScript?: string;         // Generated AS source (debug mode)
}

ℹ️ debug: true does not guarantee wat. WAT (WebAssembly text) emission is intentionally skipped for large generated modules — Binaryen's text emitter has O(n²) memory complexity and can run out of memory on big decision tables. In that case compile({ debug: true }) returns wat: undefined and sets watUnavailable: true so you can tell "WAT skipped because the module is too large" apart from a present-but-empty emission. When WAT is generated, watUnavailable is false. Outside debug mode both wat and watUnavailable are undefined. The assemblyScript source is always available in debug mode regardless of module size.

⚠️ These artifacts are a single mutually dependent unit. The marshalCode, validationCode, outputValidationCode, and wasm fields are produced by one compile() call and must be stored, transported, and executed together — never mixed across compilations. schemaHash binds only the input/output schemas (not the compiled decision logic in wasm), and only marshalCode embeds it, so schema-hash verification is a schema-identity check, not a whole-artifact integrity check. See Security: Generated Code Trust Boundary.

⚠️ Security: a CompilationResult is executable code, not inert data. The marshalCode and validationCode fields are plain JavaScript source strings that are executed via the Function constructor when the result is turned into a runnable decision (via createCompiledDecision, or the publicly re-exported loadGeneratedMarshaling / loadGeneratedValidation loaders). Executing a CompilationResult is equivalent to running trusted program input in your process. Treat it with the same trust as the JDM you compiled it from: never build a CompilationResult (or its marshalCode / validationCode fields) from data received across an untrusted boundary (network request bodies, user uploads, third-party storage, etc.), and never forward one across a trust boundary to be executed without re-establishing that its source is trusted. See Security: Generated Code Trust Boundary.

Executing Decisions

Use createCompiledDecision() to instantiate and execute compiled decisions:

import { createCompiledDecision } from '@divmain/jdm-asm';

// Create instance (instantiates WASM module)
const decision = await createCompiledDecision(compiledResult);

// Evaluate with input data
const output = await decision.evaluate({
  customerType: 'premium',
  orderAmount: 150,
});

// Reuse for multiple evaluations (recommended for performance)
for (const input of inputs) {
  const result = await decision.evaluate(input);
  processResult(result);
}

// Release resources when done
decision.dispose();

CompiledDecision API:

class CompiledDecision {
  // Evaluate with input
  evaluate<I, O>(input: I): Promise<O>;

  // Get WASM memory (debugging)
  getMemory(): WebAssembly.Memory | null;

  // Memory statistics (committed linear-memory capacity in bytes; NOT actual
  // heap/arena consumption). `committed` is an alias of `current`.
  getMemoryStats(): { current: number; maximum: number; committed: number };

  // Release resources
  dispose(): void;
}

Configuration Options

No-Match Behavior

Configure what happens when no rules match:

// Return null (default)
{ type: 'returnNull' }

// Set error code and return null
{ type: 'throwError' }

// Return a specific default value (scalars only)
{ type: 'returnDefault', value: 0 }

The returnDefault value must be a scalar (string, number, boolean, or null). Non-scalar defaults (objects or arrays) are rejected at compile time with a structured error rather than silently degrading. For a decision table the scalar default is assigned to every output field; to produce a structured no-match result, use explicit output fields or a dedicated default branch instead.

Can be set globally via compile options or per-node in the JDM content. An unknown type (for example a typo like 'returnDefaults') is likewise rejected at compile time rather than silently falling back to returnNull.

Compilation Cache

jdm-asm caches WASM compilations to disk for faster subsequent builds:

import {
  clearCache,
  getCacheStats,
  pruneCache,
  getCacheDirectory,
} from '@divmain/jdm-asm';

// Clear all cached compilations
clearCache();

// Get cache statistics
const stats = getCacheStats();

// Remove old entries
pruneCache();

JDM Format Reference

Node Types

Input Node

Entry point for the decision graph.

{
  "id": "input-1",
  "type": "inputNode",
  "name": "Request"
}

Output Node

Exit point that returns accumulated context data.

{
  "id": "output-1",
  "type": "outputNode",
  "name": "Response"
}

Expression Node

Evaluates expressions and sets output fields.

{
  "id": "expr-1",
  "type": "expressionNode",
  "name": "Calculate",
  "content": {
    "expressions": [
      { "id": "e1", "key": "total", "value": "price * quantity" },
      { "id": "e2", "key": "tax", "value": "total * 0.08" },
      { "id": "e3", "key": "grandTotal", "value": "$.total + $.tax" }
    ],
    "passThrough": true,        // Include input fields in output
    "inputField": "order",      // Scope to nested field
    "outputPath": "result"      // Store results under this key
  }
}

Self-Referential Expressions: Use $ to reference earlier computed values within the same expression node (e.g., $.total refers to the total computed above).

Decision Table Node

Rule-based decision table with conditions and outputs.

{
  "id": "table-1",
  "type": "decisionTableNode",
  "name": "Discount Rules",
  "content": {
    "hitPolicy": "first",
    "inputs": [
      { "id": "in1", "name": "Customer Type", "field": "customerType" },
      { "id": "in2", "name": "Order Amount", "field": "orderAmount" }
    ],
    "outputs": [
      { "id": "out1", "name": "Discount", "field": "discount" }
    ],
    "rules": [
      { "_id": "r1", "in1": "\"premium\"", "in2": "> 100", "out1": "0.15" },
      { "_id": "r2", "in1": "\"standard\"", "in2": "> 100", "out1": "0.10" },
      { "_id": "r3", "in1": "", "in2": "", "out1": "0" }
    ],
    "passThrough": false
  }
}

Unary Mode: In decision table cells, expressions are implicitly compared against the column's input value ($):

| Cell Value | Interpreted As | |------------|----------------| | "admin" | $ == "admin" | | > 100 | $ > 100 | | >= 18, < 65 | $ >= 18 and $ < 65 | | [1..10] | $ >= 1 and $ <= 10 | | "a", "b", "c" | $ == "a" or $ == "b" or $ == "c" | | ["gold", "platinum"] | $ in ["gold", "platinum"] | | `` (empty) or - | true (always matches) |

Switch Node

Conditional branching based on expressions.

{
  "id": "switch-1",
  "type": "switchNode",
  "name": "Route",
  "content": {
    "statements": [
      { "id": "s1", "condition": "status == 'active'" },
      { "id": "s2", "condition": "status == 'pending'" },
      { "id": "s3", "condition": "" }
    ],
    "hitPolicy": "first"
  }
}

Edges from switch nodes use sourceHandle to specify which branch they belong to (matching the statement id).

Decision Node

References external sub-decisions for modular composition.

{
  "id": "sub-1",
  "type": "decisionNode",
  "name": "Calculate Shipping",
  "content": {
    "key": "shipping-rules.json"
  }
}

The loadDecision option in compile controls how sub-decisions are loaded. The content.key above is passed verbatim to your loadDecision function; jdm-asm does not read the filesystem for sub-decisions itself.

⚠️ Security: treat content.key as an untrusted path component. Sub-decision keys are part of the decision model, so they are model-controlled input. A filesystem-backed loader that resolves keys naively — for example path.join(root, key) — will follow ../ traversal and read files outside the intended directory when given a key like ../../secrets.json. This is not a defect in jdm-asm (the library never touches the filesystem for sub-decisions); it is a responsibility of any consumer whose loader maps keys to files. A filesystem-backed loader must:

  • Confine resolution to a fixed root directory, and
  • Reject any key that escapes that root after resolution.
import path from 'node:path';
import { readFileSync } from 'node:fs';

function createSafeLoader(root: string) {
  const resolvedRoot = path.resolve(root);
  return (key: string) => {
    const target = path.resolve(resolvedRoot, key);
    // Reject anything that resolves outside the root (path traversal).
    const rel = path.relative(resolvedRoot, target);
    if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
      throw new Error(`Sub-decision key escapes root: ${key}`);
    }
    return JSON.parse(readFileSync(target, 'utf-8'));
  };
}

The filesystem loader shipped under tests/ is test-only infrastructure that deliberately uses an unguarded path.join(root, key); do not copy it into production consumers.

Function Node

Function nodes are explicitly unsupported by design. A function node embeds arbitrary JavaScript that would need a JavaScript runtime at decision-evaluation time; jdm-asm compiles decisions ahead of time to WebAssembly and has no such runtime bridge, so the code could never execute. Rather than silently emitting a placeholder that returns incorrect results, the compiler rejects any model containing a function node with a clear UNSUPPORTED_FUNCTION_NODE CompilationError. This applies on every compilation path — both the root decision and any loaded sub-decision. Express the logic using expression, decision table, or switch nodes instead.

Expression Language

jdm-asm supports the ZEN expression language:

Literals

42, 3.14, -5           // Numbers
"hello", 'world'       // Strings
true, false            // Booleans
null                   // Null
[1, 2, 3]              // Arrays
{key: value}           // Objects

Operators

| Category | Operators | |----------|-----------| | Arithmetic | +, -, *, /, %, ^ (power) | | Comparison | ==, !=, <, >, <=, >= | | Strict equality | ===, !== (aliases of ==/!=; equality is strict/non-coercing) | | Logical | and, or, not | | Null coalescing | ?? | | Membership | in, not in | | Ternary | condition ? then : else |

Access Patterns

object.property              // Member access
object["key"]                // Bracket access
array[0]                     // Index access
nested.deeply.nested.value   // Chained access

Template Literals

`Hello, ${name}!`
`Order ${id}: ${items.length} items totaling ${total}`

Interval Notation (DMN-style)

[1..10]    // 1 <= x <= 10 (inclusive)
(1..10)    // 1 < x < 10 (exclusive)
[1..10)    // 1 <= x < 10
(1..10]    // 1 < x <= 10

Built-in Functions

Array Functions

| Function | Description | Example | |----------|-------------|---------| | sum(array) | Sum numeric values | sum([1, 2, 3])6 | | avg(array) | Average of values | avg([1, 2, 3])2 | | min(array) | Minimum value | min([3, 1, 2])1 | | max(array) | Maximum value | max([3, 1, 2])3 | | count(array) | Array length | count([1, 2, 3])3 | | sort(array) | Sort ascending | sort([3, 1, 2])[1, 2, 3] | | flat(array, depth?) | Flatten nested arrays | flat([[1], [2, 3]])[1, 2, 3] | | contains(array, val) | Check membership | contains([1, 2], 2)true |

Higher-Order Functions

| Function | Description | Example | |----------|-------------|---------| | filter(array, predicate) | Filter elements | filter(items, # > 5) | | map(array, transform) | Transform elements | map(items, # * 2) | | reduce(array, expr, init) | Reduce to value | reduce(nums, acc + #, 0) | | all(array, predicate) | All match | all(scores, # >= 60) | | some(array, predicate) | Any match | some(items, # == "gold") |

Note: Use # to reference the current array element in predicates.

String Functions

| Function | Description | Example | |----------|-------------|---------| | upper(str) | Uppercase | upper("hello")"HELLO" | | lower(str) | Lowercase | lower("HELLO")"hello" | | trim(str) | Remove whitespace | trim(" hi ")"hi" | | substring(str, start, end?) | Extract substring | substring("hello", 0, 2)"he" | | indexOf(str, search) | Find index | indexOf("hello", "l")2 | | startsWith(str, prefix) | Check prefix | startsWith("hello", "he")true | | endsWith(str, suffix) | Check suffix | endsWith("hello", "lo")true | | split(str, delim) | Split to array | split("a,b,c", ",")["a", "b", "c"] | | join(array, delim) | Join to string | join(["a", "b"], "-")"a-b" | | replace(str, find, repl) | Replace first | replace("hello", "l", "L")"heLlo" | | replaceAll(str, find, repl) | Replace all | replaceAll("hello", "l", "L")"heLLo" | | contains(str, substr) | Check substring | contains("hello", "ell")true |

Math Functions

| Function | Description | Example | |----------|-------------|---------| | abs(num) | Absolute value | abs(-5)5 | | floor(num) | Round down | floor(3.7)3 | | ceil(num) | Round up | ceil(3.2)4 | | round(num) | Round nearest | round(3.5)4 |

Date/Time Functions

| Function | Description | Example | |----------|-------------|---------| | date(str) | Parse ISO date to timestamp (seconds) | date("2025-03-20T10:30:00Z") | | date("now") | Current timestamp | date("now") | | time(str) | Parse time to seconds since midnight | time("10:30:00")37800 | | duration(str) | Parse duration string | duration("24h")86400 |

Type Functions

| Function | Description | Example | |----------|-------------|---------| | number(str) | Parse to number | number("42")42 | | string(val) | Convert to string | string(42)"42" | | keys(obj) | Get object keys | keys({a: 1})["a"] | | values(obj) | Get object values | values({a: 1})[1] |

Unknown functions are rejected at compile time. A call to a function that is not a supported built-in (for example, a typo such as sbustring(...), or a zero-argument call to a nonexistent name) fails compilation with an UNKNOWN_FUNCTION CompilationError rather than silently evaluating to null.

Decision Table Hit Policies

| Policy | Behavior | zen-engine | |--------|----------|------------| | first | Return first matching rule | Yes | | collect | Return all matches as array | Yes | | unique | Return single match (error if multiple) | No | | ruleOrder | Return all matches in rule definition order | No | | outputOrder | Return all matches sorted by output value | No | | priority | Return highest priority match | No |

Architecture

Compile Phase vs Execution Phase

jdm-asm operates in two distinct phases:

Compile Phase (~250ms-10s depending on complexity/hardware):

  1. Parse JDM JSON and validate structure
  2. Build dependency graph from nodes/edges
  3. Topologically sort for evaluation order
  4. Generate AssemblyScript code for each node
  5. Compile AssemblyScript to WebAssembly
  6. Generate JavaScript marshaling code

Execution Phase (~3-1000 microseconds):

  1. Validate input against schema
  2. Marshal JavaScript object to WASM linear memory
  3. Call WASM evaluate() function
  4. Unmarshal result from WASM memory to JavaScript

This separation enables high performance: expensive compilation happens once, while fast execution occurs many times.

Data Marshaling

Data flows between JavaScript and WASM via typed memory:

JavaScript                    WASM Linear Memory
──────────                    ──────────────────
{ name: "John",     ──►      [ValueMap]
  age: 30,                     ├─ length: 2
  tags: ["a","b"] }            ├─ key0 → "name" (UTF-16)
                               ├─ val0 → [STRING, ptr → "John"]
                               ├─ key1 → "age"
                               ├─ val1 → [FLOAT, 30.0]
                               ├─ key2 → "tags"
                               └─ val2 → [ARRAY, ptr → [...]]

Value Types (tagged union):

| Tag | Type | Storage | |-----|------|---------| | 0 | Null | Tag only | | 1 | Boolean | Tag + u8 | | 2 | Integer | Tag + i64 | | 3 | Float | Tag + f64 | | 4 | String | Tag + pointer to UTF-16 data | | 5 | Array | Tag + pointer to element array | | 6 | Object | Tag + pointer to ValueMap |

Memory Layout and Region Separation

A compiled module uses a single WebAssembly linear memory shared by three consumers, laid out by fixed-address convention:

0            ~50KB              192KB                512KB
├─ static ───┼─ AS managed ─────┼─ host input ───────┼─ custom output heap ─►
│  data      │  (GC) heap ▲     │  (marshal buffer)  │  (bump allocator)
│            │  grows upward    │  up to 320KB       │  grows upward
  • Static data ends at __heap_base (the AssemblyScript-provided offset).
  • The managed (GC) heap grows upward from __heap_base. Deserializing input allocates managed Value/Map/Array/string objects here.
  • The host input / marshal buffer starts at 192KB. The JS marshal allocator rejects serialized inputs larger than 320KB (it would otherwise cross into the custom output heap at 512KB).
  • The custom output heap (a bump allocator) starts at 512KB.

These boundaries are a layout convention, not a hard partition: the incremental GC's allocator claims all free memory above __heap_base and is not told about the marshal/output regions. Separation is therefore enforced to different degrees (PERF-001):

  • Enforced (static boundary). The runtime exports getHeapBase(), and both the WASM entry point and CompiledDecision verify at instantiation that __heap_base sits below the 192KB marshal region. A module whose static data reached the input region — e.g. a decision table with an enormous number of string constants — fails closed deterministically instead of silently marshaling input over live static data.

  • Best-effort (dynamic boundary). Managed allocations made while deserializing a near-limit input could in principle grow the GC heap past the 192KB/512KB boundaries into the marshal or output region. This is bounded but not eliminated by the 320KB input cap and the module's --maximumMemory ceiling. This is a single-tenant robustness edge (there is no other tenant's memory to reach), not a sandbox-isolation concern. For very large inputs combined with large intermediate value graphs, run evaluation in an isolated worker with your own resource limits (see Runtime Input Limits below).

    This is a deliberate, accepted trade-off, not an unaddressed gap. The runtime ABI is single-tenant and non-reentrant (one evaluation per instance), so the only bytes a runaway GC heap could overwrite belong to that same evaluation. There is no cross-tenant isolation to breach, so the worst case is a corrupt result for the caller who supplied the oversized input — a condition the input-size cap and --maximumMemory ceiling already bound. Adding a per-allocation region guard would tax the evaluation hot path on every allocation for no isolation benefit, which is why only the static boundary (a single, cheap instantiation-time check) is enforced. If you need parallel or mutually isolated evaluations, use a separate compiled instance per worker; a WASM instance's linear memory is already isolated from every other instance.

Security: Generated Code Trust Boundary

A CompilationResult is executable code, not inert data. Its marshalCode and validationCode fields are plain JavaScript source strings, and the public API executes them via the Function constructor:

  • createCompiledDecision(result) runs both marshalCode and validationCode.
  • The re-exported loadGeneratedMarshaling(marshalCode) loader runs marshalCode.
  • The re-exported loadGeneratedValidation(validationCode) loader runs validationCode.

This is a deliberate design choice (generated code is emitted as source strings rather than opaque closures), which means a CompilationResult carries the same trust level as the JDM and schemas you compiled it from. Executing one is equivalent to running trusted program input inside your process.

Trust expectations:

  • Only compile trusted JDM. The contents of marshalCode / validationCode are derived from the JDM and schemas passed to compile(). Compiling attacker controlled JDM and then executing the result runs attacker-influenced code.
  • Never source a CompilationResult from an untrusted boundary. Do not build a CompilationResult (or populate its marshalCode / validationCode fields) from network request bodies, user uploads, third-party storage, or any other untrusted input, and then execute it.
  • Never forward across a trust boundary without re-validation. If a CompilationResult crosses a process/service boundary, treat the receiving side as executing untrusted code unless the source is independently trusted.

In short: pass to createCompiledDecision and the loader functions only results produced by your own compile() call on trusted inputs.

Artifact coupling — treat the outputs as one unit.

A CompilationResult's marshalCode, validationCode, outputValidationCode, and wasm fields are a single mutually dependent unit emitted by one compile() call (typically shipped together, e.g. as a WASM bundle). They must be stored, transported, and executed together and never mixed across compilations or distributed separately:

  • The marshaling code encodes the exact memory layout the wasm binary expects.
  • The validators enforce the schemas that layout was generated for.

Combining artifacts from different compilations can silently marshal or interpret data under the wrong contract.

Schema-hash verification only partially guards this:

  • Only marshalCode embeds a SCHEMA_HASH. createCompiledDecision compares it against the result's declared schemaHash and fails closed on mismatch. The generated validators carry no embedded identity of their own.
  • schemaHash binds the input/output schemas only. It does not identify or verify the compiled decision logic (the wasm binary): two compilations with identical schemas but different decision tables/expressions produce the same hash. It is a schema-identity check, not a whole-artifact integrity check, and cannot detect a wasm field swapped for one built from different logic under the same schemas.
  • The embedded-hash check is skipped when marshalCode carries no SCHEMA_HASH (it reads as undefined), for backward compatibility with artifacts generated before schema hashing existed.

Because the hash does not cover the WASM decision logic, keeping the artifacts together is the contract that guarantees integrity. Do not rely on schemaHash alone to detect a mismatched or substituted binary.

Security: Trusted Compilation Inputs

Decision models passed to compile() (the JDM graph, its expressions, and the input/output schemas) are treated as trusted program inputs, in the same sense that source code fed to a compiler is trusted. The compiler is a build-time tool, not a sandbox for hostile input.

Concretely, compile() and the parsing/graph/codegen passes it drives (parseJDM, parseExpression, buildGraph, and AssemblyScript compilation) do not impose limits on, or otherwise sandbox against, adversarial models:

  • No cap on model byte size, node count, edge count, or decision-table rule count.
  • No cap on expression length or nesting depth.
  • No cap on graph depth or sub-decision fan-out.
  • The parser, AST walkers, graph builder, and code generator recurse in proportion to model structure, so a pathologically deep or large model can drive long compile times or deep recursion.

This is a deliberate design choice consistent with the generated-code trust boundary: a model you are willing to compile is a model you already trust to run.

Caller responsibilities:

  • Only compile models you trust. Do not feed compile() JDM, expressions, or schemas sourced directly from an untrusted boundary (network request bodies, user uploads, third-party storage, etc.).
  • If you must compile externally supplied models, isolate the compile step yourself. Enforce your own size/complexity limits before calling compile(), and run compilation under appropriate resource bounds (separate process, timeouts, memory/CPU limits) that you control.

Compilation is a one-time, offline-style cost; the runtime evaluate() path is unaffected by this assumption because it executes already-compiled, bounded WASM.

Security: Runtime Input Limits and Resource Budgets

The runtime evaluate() path does not bound input depth, input size, or per-evaluation work. This is a deliberate design tradeoff: enforcing performance and resource budgets is the responsibility of the host application that evaluates compiled policies, not of this library.

Concretely, the marshaling, WASM deserialization, and output unflattening paths:

  • Recurse over nested structure with no depth budget. Input marshaling (marshalCode), the WASM-side Value deserialization, and output unflattening all recurse in proportion to the nesting depth of the payload. A sufficiently deeply nested input or output can exhaust the host (JavaScript) call stack or the WASM call stack.
  • Do not detect cycles. Cyclic input (an object or array that references itself) is unsupported; it will recurse until the stack overflows rather than being detected and rejected. Only acyclic (tree-shaped) data is supported.
  • Impose no per-evaluation quotas. There is no cap on serialized input byte size, array/object element counts, output size, or total evaluation work. The only bound is the coarse compiled WASM memory maximum, so a large but schema-valid payload can consume the whole module memory budget.

Valid, acyclic, reasonably sized payloads — including deeply-nested-but-finite structures — marshal, evaluate, and unmarshal correctly. The absence of limits is about adversarial or pathological inputs, not everyday nested data.

Caller responsibilities:

  • Bound input size and nesting depth before calling evaluate(). Reject or truncate untrusted payloads that exceed limits you choose (maximum serialized size, maximum nesting depth, maximum array/object element counts).
  • Never pass cyclic data to evaluate(). Break cycles first, or reject the input.
  • Enforce timeouts and resource bounds you control. Run evaluation under appropriate wall-clock timeouts and memory limits (for example in a worker with a watchdog) so a single large evaluation cannot monopolize resources.

Performance

Benchmark Results

How to read these numbers. They were regenerated from the benchmark harness in this repository (npm run bench), which measures jdm-asm against its public execution contract and reports three clearly separated, never-conflated metrics:

  • jdm-asm-raw — raw-WASM hot path only (marshal + evaluate + unmarshal on a pre-instantiated module, no input validation). A theoretical ceiling, not the shipped contract.
  • jdm-asm-e2e — the public API path a consumer actually uses (input validation + marshal + evaluate + runtime error checking + unmarshal). This is the number to compare against zen-engine for single-process use.
  • jdm-asm-ipc — worker round-trip from the main thread, including cross-thread (structured-clone) serialization of input and output.

Batch benchmarks cycle through several distinct, deterministic input variants (not one repeated object) and are measured at two batch sizes. Numbers vary by hardware, runtime, load, and — for the IPC path — the number of worker threads actually spawned. Treat them as indicative, and re-run npm run bench on your own hardware for decisions that depend on them.

Recorded configuration

| Property | Value | |----------|-------| | Base commit | c7d93f6 (plus the benchmark-harness changes in this change set) | | CPU | Apple M4 Pro (14 logical cores) | | Runtime | Node.js 26.3.1 (arm64-darwin) | | Comparison engine | @gorules/zen-engine 0.51.5 | | Worker threads | 2 total (1 worker × 2 pools). On this host the default worker budget resolved to 1 (memory-aware limit freemem / 256 MiB), so each of the two viable scenario pools received a single worker. | | Batch sizes | 250 and 1000 decisions, cycling distinct input variants | | Scenarios measured | Simple (merch-bags, exact-match rules) and Moderate (shipping, computed cart + tags) |

Complex (~8000-rule) scenario omitted. On this configuration the harness skips the Complex scenario during warmup: its evaluated output does not satisfy the benchmark's declared ComplexOutputSchema (a required discount field is absent), so the harness transparently drops it rather than reporting a fabricated result. Earlier "Complex" and "16-thread" rows are therefore not reproducible from the current harness and have been removed. If you need large-table numbers, fix the Complex fixture/schema pairing and re-run.

Single-Decision Latency (single-threaded)

Per-call latency with no batching or concurrency. jdm-asm-e2e is the shipped public path; jdm-asm-raw is the no-validation ceiling.

| Scenario | jdm-asm-e2e (avg) | jdm-asm-raw (avg) | zen-engine (avg) | e2e speedup | |----------|-------------------|-------------------|------------------|-------------| | Simple | 3.0 µs | 3.2 µs | 32.0 µs | ~10x | | Moderate | 6.5 µs | 6.3 µs | 34.3 µs | ~5.3x |

For typical decision tables (5-50 rules), expect sub-10 microsecond single-threaded execution times.

Single-Decision Latency Percentiles

jdm-asm-e2e (public path), with zen-engine for reference:

| Scenario | jdm-asm-e2e p50 | jdm-asm-e2e p99 | zen-engine p50 | zen-engine p99 | |----------|-----------------|-----------------|----------------|----------------| | Simple | 3.0 µs | 3.1 µs | 28.4 µs | 58.6 µs | | Moderate | 6.5 µs | 6.6 µs | 31.8 µs | 63.6 µs |

Single-Decision Throughput (decisions/second)

| Scenario | zen-engine | jdm-asm-raw | jdm-asm-e2e | |----------|------------|-------------|-------------| | Simple | 32.0K | 313.4K | 331.9K | | Moderate | 29.2K | 160.0K | 154.2K |

Single-Thread Batch Throughput (public path, varied inputs)

Batches evaluated on the main thread via the public evaluate path, cycling through distinct input variants.

| Batch size | Scenario | zen-engine | jdm-asm-e2e | speedup | |------------|----------|------------|-------------|---------| | 250 | Simple | 151.1K | 306.8K | 2.0x | | 250 | Moderate | 125.0K | 133.2K | 1.1x | | 1000 | Simple | 147.4K | 304.6K | 2.1x | | 1000 | Moderate | 118.6K | 135.3K | 1.1x |

Multi-Thread (IPC) Batch Throughput (2 worker threads total, varied inputs)

Worker round-trip throughput including cross-thread serialization. With only two worker threads available on this host, the IPC path does not universally beat in-process zen-engine: for the cheap Moderate decision the serialization overhead makes it slower. Throughput here scales with the number of workers actually spawned (see the recorded configuration).

| Batch size | Scenario | zen-engine | jdm-asm-ipc | ratio (jdm-asm ÷ zen) | |------------|----------|------------|-------------|-----------------------| | 250 | Simple | 141.0K | 147.1K | 1.04x | | 250 | Moderate | 118.4K | 85.2K | 0.72x (slower) | | 1000 | Simple | 138.8K | 155.5K | 1.12x | | 1000 | Moderate | 118.5K | 85.5K | 0.72x (slower) |

Multi-Thread IPC Round-Trip Latency (main-thread perspective)

p50 / p95 / p99 over the 1000-decision batch, 2 worker threads total:

| Scenario | jdm-asm-ipc | zen-engine | |----------|-------------|------------| | Simple | 2.5 ms / 4.9 ms / 5.3 ms | 3.6 ms / 5.4 ms / 5.9 ms | | Moderate | 4.8 ms / 9.3 ms / 9.8 ms | 4.3 ms / 6.8 ms / 7.8 ms |

Compilation Time

| Scenario | WASM Size | Compile Time | |----------|-----------|--------------| | Simple | 34 KB | 1.3s | | Moderate | 56 KB | 0.7s |

When to Use jdm-asm

Best for:

  • Ultra-low latency requirements (sub-10 µs for typical decisions)
  • High-throughput APIs evaluating hundreds of thousands of decisions per second
  • Latency-sensitive applications where p99 matters
  • Simple to moderate complexity rules (5-50 rules)
  • Cases where rules change infrequently (amortize compilation cost)

Consider zen-engine when:

  • Rules change frequently (compilation cost is high)
  • You need function nodes / custom JavaScript logic (unsupported by jdm-asm, see below)
  • Quick prototyping without schema definitions

Compatibility with zen-engine

jdm-asm is designed as a drop-in replacement for zen-engine with some differences:

Fully Supported:

  • All node types (input, output, expression, decision table, switch, decision)
  • Expression language (operators, functions, access patterns)
  • Decision table hit policies: first, collect
  • Sub-decision composition

Extended Features (not in zen-engine):

  • DMN-standard hit policies: unique, ruleOrder, outputOrder, priority
  • Compile-time optimization
  • Parallel execution via per-worker decision instances

Limitations:

  • Function nodes: Explicitly unsupported by design. jdm-asm compiles decisions ahead of time to WebAssembly and has no JavaScript runtime bridge, so the arbitrary JavaScript in a function node could never be executed. Compiling any model that contains a function node fails with an UNSUPPORTED_FUNCTION_NODE CompilationError. Express the logic using expression, decision table, or switch nodes instead.
  • Division by zero: Returns null instead of Infinity
  • Full ISO 8601 durations: Simple formats (24h, 30d) work; complex format (P1Y2M3D) not supported

Development

# Install dependencies
npm install

# Build TypeScript
npm run build

# Build AssemblyScript runtime
npm run asbuild

# Run tests
npm run test

# Run benchmarks
npm run bench

# Lint code
npm run lint

# Type check
npx tsc --noEmit

# Run tests with coverage
npm run test:coverage

Testing and Coverage

Code coverage is collected via the V8 provider (@vitest/coverage-v8) and covers src/**/*.ts. The src/runtime/** directory is intentionally excluded from coverage: the AssemblyScript runtime is compiled to WebAssembly and executed as WASM rather than run directly as TypeScript under V8, so the coverage tooling cannot observe its execution. Including it would falsely report near-zero coverage for code that is actually exercised.

The runtime's behavior is instead validated behaviorally by the WASM integration/execution test corpus in tests/integration, which compiles JDM models and runs the resulting WebAssembly end-to-end. Coverage is informational only — no coverage thresholds are configured, so a coverage run never fails the build on numeric grounds.

Project Structure

jdm-asm/
├── src/
│   ├── compiler/       # TypeScript compiler code
│   └── runtime/        # AssemblyScript runtime
├── tests/
│   ├── unit/           # Unit tests
│   ├── integration/    # Integration tests
│   └── helpers/        # Test utilities
├── test-data/          # Ported zen-engine test fixtures
├── benchmarks/         # Performance benchmarks
├── build/              # Compiled WASM output
└── dist/               # Built npm package

License

MIT License - see LICENSE for details.