bonsai-js 1.0.0-rc.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,50 @@ From 1.0.0, entries are generated at release time from the bump files in
7
7
  [`.bumpy/`](./.bumpy/); entries up to 0.5.0 follow
8
8
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
9
9
 
10
+ ## 1.0.0
11
+ <sub>2026-10-19</sub>
12
+
13
+ - *(major)*
14
+ A redesign of the language and the engine. Expressions and host code written for 0.x need migrating; the [migration guide](https://danfry1.github.io/bonsai-js/guide/migrating) covers every change, including results that change without an error.
15
+
16
+ **Language**
17
+
18
+ - Every function is callable as `f(x, a)` or `x.f(a)`. The pipe operator `|>` is removed and its token reserved.
19
+ - The standard library is built in, with JavaScript names (`toUpperCase`, `includes`, `filter`) plus `sum`, `sortBy`, `groupBy`, `unique`, `keys`, `round(x, digits)`, and calendar functions with time zones. `upper`, `lower`, `flatten`, `isString`, `isNumber`, `isArray`, `isNull`, `toBool`, `diffDays`, `charCodeAt`, `toSpliced`, `with`, `substring`, `charAt`, and `concat` are removed.
20
+ - Lambdas: `x => ...` and `(acc, x) => ...` alongside `.field`. The implicit `.` binds to the nearest argument whose parameter takes a function, and only where that function's first parameter is the current item (so not in `reduce`). No-argument `filter`, `some`, and `every` are removed.
21
+ - New: `let` bindings, `try(expr, fallback)`, `has(a.b)`, comments, timestamps and durations (`now() - t > days(30)`), deep `==` on lists and maps. Durations are whole milliseconds (a fraction rounds, halves away from zero), so their arithmetic is exact, within ±(2^53 - 1) ms (about 285,000 years); a duration outside that range is `INVALID_ARGUMENT`, and `new Duration(ms)` throws a `RangeError` for one. Time zones are `"UTC"` or IANA `Area/Location` names in the tz database's spelling; abbreviations, legacy names, other spellings, and offsets are `INVALID_ARGUMENT`.
22
+ - `null` is the single absent value: missing properties and host `undefined` read as `null`, and `x == null` holds for both. A map key holding `undefined` is absent to `==`, `keys`, `values`, `entries`, `has`, `in`, and spread. Ordering comparisons with `null` are `false`.
23
+ - `&&`, `||`, `!`, and `?:` take booleans (`null` counts as false). There is no coercion: `"a" + 1` and `null + 1` are errors. Division and remainder by zero and non-finite results are errors. Chained comparisons are parse errors, and so is `??` beside another binary operator without parentheses (`a ?? 0 > 10`).
24
+ - Results that changed: `round` rounds halves away from zero and honors its digits argument; `toFixed` rounds the written decimal; `avg([])` is `null`; `unique` compares by value; templates render `null` as empty text, dates as ISO-8601, and reject lists and maps; `toString(null)` is `""`; `toNumber` accepts decimal text only (no `0x`, `0b`, or `0o`); `formatNumber` and `formatCurrency` reject a locale the runtime has no data for instead of using the host's default, a locale tag over 64 characters, and a currency that is not a three-letter code, and never show a negative zero (an amount that rounds to zero at the shown precision prints unsigned, so not `-$0.00`); `round` leaves a number of 2^53 or more as it is at zero or more digits; `toFixed` writes every digit of a number of 1e21 or more instead of exponent form; `formatDate` reads `''` inside quotes as a quote and rejects a quote left open; `timestamp()` reads back the six-digit signed years a timestamp renders outside 0000-9999; `sum` and `avg` of durations check the range at each step, as `a + b + c` does; `sum` of a list of durations sums them whether or not the list's type is known; `sum` and `avg` add left to right; `now()` is a timestamp; `sort()` orders numbers numerically; `includes`, `indexOf`, and `in` compare lists and maps by value, and `Date`s at the same instant are equal; `replace` and `replaceAll` insert their replacement text literally (no `$&` patterns).
25
+ - Only plain objects and class instances are read as maps: reads see their own properties, and `keys()`, spread, and `==` see the enumerable ones, as in JavaScript. `Map`, `Set`, `RegExp`, promises, typed arrays, and errors are opaque; a plain object with a `then` method is an ordinary map and is never awaited. Map keys follow JavaScript property order. A computed read of `__proto__`, `constructor`, or `prototype` is `null`; writing one is a `BLOCKED_PROPERTY` error. `groupBy` keys are text, so `1` and `"1"` share a group.
26
+ - `duration("PT1H30M")` reads ISO-8601 duration text, the text a duration renders as (weeks down to milliseconds; years and months have no fixed length and are refused). `min`, `max`, and `avg` take durations as `sum` does, given as a list or one by one (`max(a, b)`). A reserved word is not a method name: `a.not(b)` is a syntax error. Syntax errors suggest `**` for `^` and `!=` for `<>`. A `\u{...}` escape takes any number of leading zeros, as in JavaScript, and `1.e3` (a read of `e3` on `1`) is a check error that says to write `1e3`.
27
+
28
+ **API**
29
+
30
+ - `bonsai({ variables, strict, functions, libraries, limits, cacheSize, clock, validateContext })` creates an immutable environment; `extend()` derives one. `use()`, plugins, `addFunction`, `addContextFunction`, `addTransform`, `remove*`, `has*`, `isContextFunction`, `getPolicy`, and `clearCache` are removed.
31
+ - Declaring `variables` makes an environment strict: undeclared names are check errors unless `strict: false`.
32
+ - Host functions are declared with `fn({ params, returns, run })`, validated on every call, and must be marked `async` to be awaited. With `call: true`, `run` first receives `{ context, signal }`: the read-only context, and a signal that aborts when the evaluation times out or is cancelled while waiting on the call. A result that does not match the declared type, or a promise from a function not marked `async`, is a `HOST_CONTRACT` error that `try()` does not catch.
33
+ - `env.compile(source, { expect })` type-checks and returns a `Program`, cached per source and expected type; `env.check()` replaces `validate()` and returns every diagnostic without throwing. Evaluation options include `now`, the time `now()` returns for that evaluation, and `validateContext`, which turns context validation on or off for that evaluation (context validation rejects `NaN` and infinities where a number is declared), and a `BonsaiLimitError` names the option that bounds it in `limit`. The context type comes from `variables` and the variables of the `libraries` used, instead of `bonsai<Ctx>()`. A schema typed `Record<string, Type>` accepts interface-typed and class contexts, and a variable `extend()` declares again replaces the earlier one in the context type. A context that is not an object is a `TypeError`.
34
+ - `bonsai-js/service` (`createLanguageService`) replaces `bonsai-js/autocomplete`; the `stdlib` and `autocomplete` subpaths are removed. A source that is not a string or an offset that is not a number is a `TypeError`, and a negative or fractional offset a `RangeError`.
35
+ - `print(tree, { calls })` turns a syntax tree back into source, round-tripping with `env.parse`. Syntax tree node types are renamed. `forEachChild` walks a tree and `mapChildren` rewrites one, both visiting children in evaluation order; member and call nodes record the span of their name (`nameStart`, `nameEnd`; optional in trees built by hand).
36
+ - `program.explain(context)` (and `explainSync`, `env.explain`, `env.explainSync`) evaluates and returns a trace of every sub-expression's value, with skipped branches and per-item lambda runs; `reasons()` lists the conditions that decided the result, a negated condition as written (`!("vip" in tags)`, with its own value). Trace text is capped at 120 characters, and no cut in a trace or its rendering splits a surrogate pair. `String(explanation)`, `JSON.stringify(explanation)`, `JSON.stringify(explanation.reasons())`, and `JSON.stringify(explanation.trace)` are bounded and cycle-safe, and any trace, lambda run, or list of them picked out of it serializes as its part of `JSON.stringify(explanation)`; a map shows its first 50 keys and is listed once per explanation, so serializing never grows with its size, and trace records stay plain objects. An explanation serializes to the same frozen snapshot every time, whatever was serialized before. `explain()` refuses a promise-like result as `evaluate()` does, and marks a result refused after evaluation (a promise-like value, a failed `expect` check) on the root of the trace.
37
+ - `program.partial(known, { unknown })` (or `env.partial(source, known)`) evaluates what the known data decides and returns a value, an error, or a residual expression over the unknown variables (its tree is `ast`, as on a program), to pre-evaluate rules once per tenant or request. Data missing from `known`, including a field missing from a known object, and a known object the expression reads whole, is unknown unless an `unknown` list is given; with one, an object above a listed path that `known` leaves out is unknown as a whole, not null. An `unknown` path starts with a variable name and names fields as reads spell them; an empty or blocked segment is a `TypeError`. It takes the same step, time, and cancellation budgets as evaluation; a size limit (`LIST_LIMIT`, `STRING_LIMIT`, `VALUE_DEPTH_LIMIT`, `PATTERN_LIMIT`) in a known part that evaluation may not reach stays in the residual rather than ending `partial()`, and a list literal over `maxListLength` reports that limit before its items, as evaluation does, in async programs too. Known data that throws when read (a getter, a Proxy trap) fails at that read, as in evaluation, so a read behind `&&` or inside `try()` decides as the program does, and no getter runs to decide which variables are given. A residual longer than `maxSourceLength` is a `SOURCE_TOO_LONG` limit error (`toSQL` and `toMongo`, which never evaluate its text, are not bound by it), and a residual can be explained like a program, at the step cost of evaluating it. A residual runs every call with the overload the program chose, and a part whose inputs all turn out known (`(2 ?? row.total) >= 1`) is decided rather than left in the residual. A residual's bindings are the known values themselves (shared with `known`, not copied), and `call: true` host functions in a residual read exactly the context it is given (`readsContext` says when it calls one; that context must then hold every variable `known` gave, or the call is an `INVALID_CONTEXT` error). With `validateContext`, `partial()` validates the known data (all but what lies under a listed unknown path), and a residual validates the paths it reads, each step against its own declared type, so a context holding only `dependsOn` passes and invalid data on those paths fails as it would for the program (the whole context when `readsContext`). `PartialResult` and `ResidualResult` take the context type first, as `Program` does. Partial evaluation keeps memory in proportion to the program and charges the work of indexing and compiling it.
38
+ - `bonsai-js/query` (`toSQL`, `toMongo`) translates a filter over a declared record into a parameterized SQL `WHERE` clause (Postgres, SQLite) or a MongoDB filter that selects exactly the records the filter accepts. This covers the null-safe text forms (`x?.endsWith(k) ?? false`, `(x ?? "").endsWith(k)`), columns with a known default (`(order.total ?? 0) >= 500`), duration columns, and relative dates (`now() - order.placed < days(14)`). A path the filter reads that is missing from `known` is refused rather than read as null (in a typed environment, so is a known object read whole without every declared field), and every refusal says why. A known value that throws when read is refused with the host's error as the `cause`. One `maxSteps`, `timeout`, and `signal` budget covers the whole translation, including the partial evaluation it runs, and in SQL a known value is sent once however often the filter uses it. SQL arithmetic covers unary `-` as well as `+ - *`; in Postgres a record whose arithmetic leaves the double range fails the query with `value out of range` (never a different set of rows), where SQLite excludes it. `params` is typed so `node:sqlite` and Postgres drivers take it without a cast. Known dates and durations are checked as evaluation checks them, so a value evaluation treats as opaque is refused. A comparison whose sides are both known is decided before translating, a known list with repeated entries matches as its entries, and a text function on `(field ?? default)` runs on the default where the field is null, so it is never null. A part that fails whenever it is reached after a read of the record (a list literal over `maxListLength`, say) is refused as `UNTRANSLATABLE` (naming its error), never sent to the database, and a relative date excludes a record whose distance from the known time leaves the duration range, as evaluation fails it. Records are not validated: with `validateContext`, the database must enforce the declared types for exact results. With `callHostFunctions: true`, sync host functions with known inputs are called before translating. `BonsaiTranslationError` is exported from `bonsai-js` as well as `bonsai-js/query`.
39
+ - Errors are `BonsaiError` subclasses with stable codes and `{ start, end }` spans. Diagnostics carry the same `span`, `position`, and `formatted` code frame, plus a `suggestion` for an unknown name; `env.check()` also returns the checked tree, the type of each node, and (when ok) the compiled program. Any `BonsaiError` that a getter or Proxy in the context throws (from another evaluation, a parse, a check, or a translation it runs, or one the host built) is a `HOST_ERROR` with it as `cause`, as for host functions, in evaluation, explanation, validation, and `partial()` alike; only the errors an evaluation raises itself keep their code. Any other thrown value becomes a `HOST_ERROR` too, including one whose inspection throws (a revoked Proxy), so `try()` recovers from it, and every trace node an explanation records for it says `HOST_ERROR`; `explain({ exhaustive: true })` ignores it in the extra work, whatever it is. A stack overflow in a host function is a `HOST_ERROR` like anything else it throws; one in a getter or Proxy trap is `TOO_DEEP` (the engine's error is its `cause`), which `try()` does not recover from and `partial()` leaves to a branch that may not run. Only the engines' own overflow errors count as overflows, not any `RangeError`. Async evaluation never awaits a value from the data: a thenable in the context stays opaque inside every node, lambda, and host call result (an `async` host function's own result is awaited by JavaScript, so it must not return one). Async evaluation and explanation check list limits, expected types, and the deadline in the same order as sync evaluation, so they run the same host calls and report the same error. A host call's signal is aborted with a copy of the evaluation's error, so host code rethrowing it elsewhere is that evaluation's `HOST_ERROR`. `ExpressionError`, `BonsaiTypeError`, `BonsaiReferenceError`, `BonsaiSecurityError`, `formatError`, `formatBonsaiError`, `evaluateExpression`, `tokenize`, and `compile` are removed (`parse(source)` returns the new syntax tree), as are the `allowedProperties` and `deniedProperties` options.
40
+ - `JSON.stringify(error)` gives one documented shape (`BonsaiErrorJSON`: name, code, message, span, position, plus `limit` or `diagnostics`) wherever an error is serialized. Check results, their diagnostics, and partial results are frozen. A program's environment types are checked against `toSQL` and `toMongo` column types. `DiagnosticJSON` types the diagnostics in that JSON, without the `formatted` code frame. `isBonsaiError` also recognizes errors from another copy of the package.
41
+ - Limits move under `limits`; `maxArrayLength` is now `maxListLength`, and `maxDepth` now bounds syntax nesting (`TOO_DEEP`); nested data is bounded by `maxValueDepth` (`VALUE_DEPTH_LIMIT`), counted alike when building, comparing, `unique()`, and validating. Built-ins check `maxListLength` before they build a list, `groupBy` groups included.
42
+ - `maxDepth` is at most 256 and `maxValueDepth` at most 1,024 (a larger value is a `RangeError`), so every stage stays inside the JavaScript call stack; a stack overflow anywhere is a `TOO_DEEP` limit error, never a raw `RangeError`. Host `Duration` values are read by their own frozen length and rendered by the engine, never by their own methods; one built without the constructor is an opaque value.
43
+ - `expect` means "has at least these fields": an extra field in a map literal is an `EXPECTED_TYPE` warning, and the result keeps it. Literal locales, currencies, and duration text are checked before anything runs. In a non-strict environment with declared variables, an undeclared name one edit from a declared one is an `UNKNOWN_VARIABLE` warning with a `suggestion`. Diagnostic messages shorten type text to 120 characters.
44
+ - Spans are balanced: a parenthesized expression's span includes its parentheses, so slicing the source at any node, error, diagnostic, or explanation span gives source that parses on its own. A later computed key or spread record makes earlier map fields it may overwrite (and keys it may leave absent) part of the checked type, and `print()` keeps the parentheses of a right-nested `&&`, `||`, or `??` so the printed tree parses back to the same shape. `createLanguageService(env, { expect })` adds `EXPECTED_TYPE` to the service's diagnostics, its completions work inside `try()` and computed keys with the same types check() gives, and a negative or fractional offset is a `RangeError`. An ordering with a value that may be `null` is a `MAYBE_NULL` warning only where it is written so its `false` becomes a verdict (under `!`, as a `?:` test, in `every` or `none`, or compared with `false`, looking through `?:` branches, `let` bodies, `try`, and `== true`), not where it means "skip" (`users.filter(.age >= 18)`); it is a heuristic over how the comparison is written, and a literal written with an exponent is an `UNSAFE_INTEGER` warning only when the number cannot keep the digits written (`9007199254740993e0`, not `1e308` or an exact `1.180591620717411303424e21`). A computed or `groupBy` key that is always one evaluation blocks (`{["__proto__"]: 1}`, `` {[`constructor`]: 1} ``) is a `BLOCKED_PROPERTY` error at check time, a template without substitutions is typed as its text, and a key a map literal writes is always present when spread, and a spread after an open declared object keeps any key it may lack typed as whatever the object held. Code frames and shortened type text never split a surrogate pair, a CRLF line is shown without its `\r`, and the caret lines up by characters. Completions also work inside brackets, calls, templates, and branches of a `let` value, nested ones included (`let v = try(b ? user.`), and inside `has()`, and detail each field with the type the read has there, narrowing included. A completion costs at most 100 parses, and about 12 of a source near the length limit.
45
+ - `withContext<Ctx>()` builds host functions typed against a context, `Infer` gives the TypeScript type of a `t` type, `formatType` and `isAssignable` print and compare types, and `env.describeFunction` / `env.listFunctions()` (read-only) describe the callable functions for editors and builders. Error classes keep their `name` (and the JSON `name`, typed `BonsaiErrorName`) in minified bundles; a call stack overflow is `TOO_DEEP` with no `limit`, since no option raises it. `ExplanationJSON` types `explanation.toJSON()`, `SQLParam` and `QueryOptions` name the query types, and the standalone `parse(source, limits)` ignores the limits parsing does not use. The [stability policy](https://danfry1.github.io/bonsai-js/guide/stability) says what a minor release may change; the result type parameter of `env.evaluate<R>()` and the other environment calls is an unchecked assertion.
46
+ - Requires Node.js 22 or newer.
47
+
48
+ **Engine**
49
+
50
+ - Expressions compile to closures. Async code is generated only for subtrees that reach an async host function. Compiling an expression that uses time zones loads their data (each zone it names once, and at most 16 per expression), so a first evaluation does not spend its `timeout` on it; a zone that comes from data, or that `partial()` writes into a residual, loads on first use.
51
+ - The package ships one module per source file, so bundlers drop what an app does not reach: importing only `t` adds under 1 KB gzipped, and all the error classes about 1.6 KB. The `bonsai-js/service` entry is about 46 KB gzipped, since it includes the type checker. A minimal browser bundle that evaluates expressions is about 68 KB gzipped, and about 56 KB from `bonsai-js/lite`, which exports the same API without explaining and partial evaluation (`LiteEnvironment`, `LiteProgram`, and `LiteCheckResult` type its environments; the language service accepts them, and `toSQL` and `toMongo`, which translate through partial evaluation, say they cannot take its programs).
52
+ - Every limit is on by default: source size, depth, node count, a deterministic step budget charged by the real cost of each operation, produced string and list sizes, value depth, regular expression pattern length, plus an optional timeout (in milliseconds, fractions allowed; only `0` turns it off) and `AbortSignal`. A built-in call is charged by the length of its string arguments, and an `ABORTED` error carries the signal's reason as its `cause`.
53
+
10
54
  ## [0.5.0] - 2026-06-02
11
55
 
12
56
  ### Security
package/README.md CHANGED
@@ -17,46 +17,64 @@
17
17
 
18
18
  A small, safe, typed expression language for rules, filters, formulas, and templates that people other than your developers write. Zero dependencies, runs in any modern JavaScript runtime, and never executes JavaScript.
19
19
 
20
+ **Who it's for:** teams whose customers, admins, or operations staff write logic into the product (eligibility and pricing rules, saved filters, formula fields, notification templates) and who need to reject a broken rule when it is saved, explain its result, and run it safely.
21
+
20
22
  ```ts
21
23
  import { bonsai, t } from 'bonsai-js'
22
24
 
23
25
  const env = bonsai({
24
- variables: {
25
- user: t.object({ age: t.number(), plan: t.enum('free', 'pro'), tags: t.list(t.string()) }),
26
- orders: t.list(t.object({ total: t.number(), paid: t.boolean(), created: t.timestamp() })),
27
- },
26
+ variables: { user: t.object({ age: t.number(), plan: t.enum('free', 'pro') }) },
28
27
  })
29
28
 
30
- const rule = env.compile(
31
- 'user.plan == "pro" && orders.filter(.paid && now() - .created < days(30)).map(.total).sum() > 100',
32
- { expect: t.boolean() },
33
- )
29
+ // Reject a broken rule when it is saved, before it ever runs.
30
+ // Its message: Property "agee" does not exist on user; did you mean "age"?
31
+ const [problem] = env.check('user.agee >= 18').diagnostics
32
+ problem?.suggestion // => "age"
34
33
 
35
- rule.evaluateSync({
36
- user: { age: 36, plan: 'pro', tags: [] },
37
- orders: [{ total: 150, paid: true, created: new Date() }],
38
- }) // => true
34
+ const rule = env.compile('user.age >= 18 && user.plan == "pro"', { expect: t.boolean() })
35
+ rule.evaluateSync({ user: { age: 36, plan: 'pro' } }) // => true
36
+
37
+ // Show the author why it was rejected
38
+ const why = rule.explainSync({ user: { age: 16, plan: 'free' } }, { exhaustive: true })
39
+ why.reasons().map((r) => r.text) // => ["user.age >= 18", "user.plan == \"pro\""]
39
40
  ```
40
41
 
42
+ Try it in the [Playground](https://danfry1.github.io/bonsai-js/playground).
43
+
44
+ ## Why Bonsai
45
+
46
+ - **Familiar.** JavaScript syntax and JavaScript names: `a.b`, `?.`, `??`, templates, `filter`, `map`, `includes`, `toUpperCase`. Every function also works as a method, so `sum(xs)` and `xs.sum()` are the same call.
47
+ - **Mistakes are caught when a rule is saved, not in production.** Declare your data once with `t`: `user.agee >= 18` is rejected with `did you mean "age"?` and an exact source range, and `user.nick.toUpperCase()` on an optional field is flagged as possibly null. TypeScript types for the context and result come from the same declaration.
48
+ - **Every result can be explained.** `explain()` records the value of every sub-expression, and `reasons()` returns the conditions that decided the result, so a "why was this rejected" screen can list `user.age >= 18` and `user.plan == "pro"`.
49
+ - **Safe by construction, and every evaluation is bounded.** No prototype access, no globals, no calling functions found in data, no conversion hooks, no mutation: `user.constructor` is rejected when the expression is parsed. A deterministic step budget is on by default and charges each operation for the work it does (including text search, regular expressions, sorting, and time zone math), so three nested `map` calls over a 1,000-item list stop with `STEP_LIMIT` within milliseconds. Produced strings, lists, and nested values are size-limited (`"x".repeat(1000000000)` fails with `STRING_LIMIT`), and `matches()` runs in linear time, so `(a+)+$` cannot hang. A timeout and an `AbortSignal` bound time spent waiting on your own host functions.
50
+ - **One rule, three places to run it.** `partial()` evaluates what it can and returns the rest: knowing only `{ user: { plan: "pro" } }`, `user.plan == "pro" && order.total > 100` becomes `order.total > 100`. `bonsai-js/query` turns that into a parameterized SQL `WHERE` clause (Postgres, SQLite) or a MongoDB filter.
51
+ - **Editor-ready.** `bonsai-js/service` provides completions (after `user.` it offers `age` and `plan`, then the methods that apply), hover types, and diagnostics without evaluating anything.
52
+ - **Fast.** Expressions compile to closures once (no `eval`, CSP-safe). A typical rule evaluates about 10 million times per second on Node, 1.1x to 2.8x faster than `@marcbachmann/cel-js` on the same workloads (`bun run bench` reproduces this with `benchmarks/vs-cel.bench.ts`).
53
+
54
+ ## Compared with other libraries
55
+
56
+ | | Bonsai | cel-js | zen-engine | json-rules-engine | json-logic-js | jsonata | jexl |
57
+ | --- | --- | --- | --- | --- | --- | --- | --- |
58
+ | Rules are | text, JS-like | text, CEL | text and JSON decisions | JSON | JSON | text | text, JS-like |
59
+ | Browser size (min+gzip) | 67.6 KB | 24.3 KB | 4.1 KB JS + 470 KB WASM | 24.3 KB | 2.0 KB | 26.5 KB | 6.1 KB |
60
+ | Type check against a schema | Yes, with suggestions | Yes | Yes | No | No | No | No |
61
+ | Explains results | Yes | No | Per decision node | Per condition | No | No | No |
62
+ | Evaluation budget for untrusted input (as documented) | Yes, on by default | Parse-time limits only | No expression budget | No | No | No | No |
63
+ | SQL or MongoDB translation | Yes | No | No | No | No | No | No |
64
+
65
+ Bonsai is the largest of these in the browser (about 56 KB from `bonsai-js/lite`, without explaining and partial evaluation) and the newest. If rules must also run in Go or Java, CEL or ZEN fit better; if rules are JSON produced by a form builder and size matters most, json-logic-js does. The [full comparison](https://danfry1.github.io/bonsai-js/guide/comparison) covers more libraries, throughput, and the method behind every number.
66
+
41
67
  ## Install
42
68
 
43
69
  ```bash
44
- npm install bonsai-js@next
70
+ npm install bonsai-js
45
71
  ```
46
72
 
47
- This README describes 1.0, which is in release candidate on the `next` tag; `npm install bonsai-js` still installs 0.5.0 until 1.0.0 is released.
48
-
49
- Requires Node.js 22 or newer, current Bun, or a modern browser. The package is ESM; `require('bonsai-js')` works where Node can load ES modules synchronously (22.12 and newer).
50
-
51
- In a browser bundle, an app that evaluates expressions adds about 64 KB minified and gzipped: the parser, type checker, compiler, and built-in library, with explaining and partial evaluation included. `bonsai-js/query` and `bonsai-js/service` are separate entry points; the language service on its own is about 44 KB, since it includes the type checker. Unused modules are tree-shaken: importing only `t` adds under 1 KB, and all the error classes about 1.4 KB. CI enforces these budgets (`bun run check:size`).
73
+ Upgrading from 0.x? The [migration guide](https://danfry1.github.io/bonsai-js/guide/migrating) covers every change.
52
74
 
53
- ## Why Bonsai
75
+ Requires Node.js 22 or newer, current Bun, or a modern browser. The package is ESM; `require('bonsai-js')` works where Node can load ES modules synchronously (22.12 and newer); for the TypeScript settings that allow it, see [Install](https://danfry1.github.io/bonsai-js/guide/install#typescript-settings).
54
76
 
55
- - **Familiar.** JavaScript syntax and JavaScript names: `a.b`, `?.`, `??`, templates, `filter`, `map`, `includes`, `toUpperCase`. Every function also works as a method, so `sum(xs)` and `xs.sum()` are the same call.
56
- - **Checked.** Declare your data once with `t` and get errors for typos, wrong types, and possible nulls before an expression is saved, with "did you mean" suggestions and exact source ranges. TypeScript types for the context and result are inferred from the same declaration.
57
- - **Safe by construction.** No prototype access, no globals, no calling functions found in data, no conversion hooks, no mutation. Every evaluation terminates: a deterministic step budget charges each operation for the work it does (including text search, regular expressions, sorting, and time zone math), and produced strings, lists, and nested values are size-limited. A timeout and an `AbortSignal` bound time spent waiting on your own host functions.
58
- - **Fast.** Expressions compile to closures once (no `eval`, CSP-safe). A typical rule evaluates about 10 million times per second on Node, 1.1x to 2.8x faster than `@marcbachmann/cel-js` on the same workloads (`bun run bench` reproduces this with `benchmarks/vs-cel.bench.ts`).
59
- - **Editor-ready.** A language service provides completions (including methods applicable to each type), hover, and diagnostics without evaluating anything.
77
+ In a browser bundle, an app that evaluates expressions adds about 68 KB minified and gzipped: the parser, type checker, compiler, and built-in library, with explaining and partial evaluation included. `bonsai-js/lite` leaves those two out, for about 56 KB. `bonsai-js/query` and `bonsai-js/service` are separate entry points; the language service on its own is about 46 KB, since it includes the type checker. Unused modules are tree-shaken: importing only `t` adds under 1 KB, and all the error classes about 1.6 KB. CI enforces these budgets (`bun run check:size`).
60
78
 
61
79
  ## The language in one screen
62
80
 
@@ -76,7 +94,7 @@ startOfDay(order.placedAt, "Europe/Berlin") // calendar math in time
76
94
  // comments work too
77
95
  ```
78
96
 
79
- The full reference is [docs/language.md](./docs/language.md). The rules that matter most:
97
+ The full reference is [docs/language.md](https://github.com/danfry1/bonsai-js/blob/v1.0.0/docs/language.md). The rules that matter most:
80
98
 
81
99
  - There is one absent value, `null`. Missing properties and host `undefined` read as `null`, so `x == null` means "missing or null". A key holding `undefined` is absent everywhere: `{ a: 1, b: undefined }` equals `{ a: 1 }`, and `"b" in` it is false.
82
100
  - `==` compares by value (lists and maps deeply). There is no type coercion: `1 == "1"` is `false` and `"a" + 1` is an error.
@@ -97,10 +115,11 @@ await env.evaluate('price * qty', { price: 3, qty: 4 }) // => 12
97
115
  const program = env.compile('items.filter(.active).length')
98
116
  const items = [{ active: true }, { active: false }]
99
117
  program.evaluateSync({ items }) // => 1
100
- program.evaluateSync({ items }, { timeout: 50, maxSteps: 10_000, signal: AbortSignal.timeout(1_000) }) // => 1
118
+ program.evaluateSync({ items }, { timeout: 50, maxSteps: 10_000 }) // => 1
119
+ await program.evaluate({ items }, { signal: AbortSignal.timeout(1_000) }) // => 1
101
120
  ```
102
121
 
103
- `evaluateSync` compiles the source once and caches the program; `evaluate` is needed only for expressions that call async host functions.
122
+ `evaluateSync` compiles the source once and caches the program; `evaluate` is needed only for expressions that call async host functions. A `signal` cancels an evaluation while it waits on async host functions; nothing outside a synchronous run can abort it while it runs, so it is bounded by `maxSteps` and `timeout`.
104
123
 
105
124
  Without declared variables an environment is open: unknown identifiers read the context and are typed `any`. With `variables`, the environment is strict: the checker knows their types and rejects any other name. Pass `strict: false` to let undeclared names read the context as `any`.
106
125
 
@@ -111,16 +130,16 @@ import { bonsai, t } from 'bonsai-js'
111
130
 
112
131
  const env = bonsai({ variables: { user: t.object({ age: t.number(), nick: t.optional(t.string()) }) } })
113
132
 
114
- env.check('user.agee > 18')
115
- // { ok: false, type: { kind: 'boolean' }, diagnostics: [{ code: 'UNKNOWN_PROPERTY', message: 'Property "agee" does not exist on { age: number, nick: string | null }; did you mean "age"?', severity: 'error', start: 5, end: 9, span: { start: 5, end: 9 }, position: { line: 1, column: 6 }, suggestion: 'age' }] }
133
+ const checked = env.check('user.agee > 18')
134
+ checked.ok // => false
135
+ checked.diagnostics // => [{ code: "UNKNOWN_PROPERTY", message: "Property \"agee\" does not exist on user; did you mean \"age\"?", severity: "error", start: 5, end: 9, span: { start: 5, end: 9 }, position: { line: 1, column: 6 }, suggestion: "age" }]
116
136
 
117
- env.check('user.nick.toUpperCase()')
118
- // NULLABLE_RECEIVER: The value before .toUpperCase() may be null; use ?.toUpperCase() or ?? to supply a default
137
+ env.check('user.nick.toUpperCase()').diagnostics.map((d) => `${d.code}: ${d.message}`) // => ["NULLABLE_RECEIVER: The value before .toUpperCase() may be null; use ?.toUpperCase() or ?? to supply a default"]
119
138
 
120
139
  env.compile('user.age + 1', { expect: t.boolean() }) // throws: CHECK
121
140
  ```
122
141
 
123
- Types: `t.string()`, `t.number()`, `t.boolean()`, `t.null()`, `t.timestamp()`, `t.duration()`, `t.literal(v)`, `t.enum(...values)`, `t.list(T)`, `t.object({...})`, `t.record(V)`, `t.optional(T)`, `t.union(...)`, `t.any()`. `Infer<typeof type>` gives the TypeScript type.
142
+ Types: `t.string()`, `t.number()`, `t.boolean()`, `t.null()`, `t.timestamp()`, `t.duration()`, `t.literal(v)`, `t.enum(...values)`, `t.list(T)`, `t.object({...})`, `t.record(V)`, `t.optional(T)`, `t.union(...)`, `t.any()`, `t.opaque(name)` (a host value expressions can hold but not read), `t.never()`. `Infer<typeof type>` gives the TypeScript type.
124
143
 
125
144
  ## Host functions
126
145
 
@@ -152,6 +171,38 @@ await env.evaluate('hasRole("admin") || orders.map(fxRate(.currency) * .amount).
152
171
 
153
172
  Context data is not validated against the declared types by default (it is your data, and validation costs time proportional to it). Pass `validateContext: true` to check it before every evaluation; a mismatch fails with `INVALID_CONTEXT` and the exact path, e.g. `user.created should be timestamp, got string "2026-01-01"`. Objects may carry more keys than their declared type lists (a database row with extra columns is fine); the checker accounts for that.
154
173
 
174
+ ## Errors
175
+
176
+ Every error thrown while checking or evaluating an expression is a `BonsaiError` with a stable `code`, the `source`, a `span` (`{ start, end }`), a 1-based `position`, and a `formatted` message with a code frame. That includes failures in your own code: a host function that throws, or a getter or Proxy in the context that throws when read, becomes a `HOST_ERROR` with the original error as its `cause`. Invalid configuration passed to `bonsai()` or `fn()`, and a syntax tree `print()` cannot print faithfully, throw a `TypeError` or `RangeError` immediately; invalid per-evaluation options do too from `evaluateSync()`, and make `evaluate()` reject.
177
+
178
+ | Class | Codes |
179
+ |---|---|
180
+ | `BonsaiSyntaxError` | `SYNTAX` |
181
+ | `BonsaiCheckError` | `CHECK` (see `.diagnostics`) |
182
+ | `BonsaiLimitError` | `SOURCE_TOO_LONG` `TOO_DEEP` `TOO_MANY_NODES` `TOO_COMPLEX` `STEP_LIMIT` `STRING_LIMIT` `LIST_LIMIT` `VALUE_DEPTH_LIMIT` `PATTERN_LIMIT` `TIMEOUT` `ABORTED` |
183
+ | `BonsaiRuntimeError` | `TYPE_ERROR` `NO_OVERLOAD` `NULL_RECEIVER` `DIVISION_BY_ZERO` `NON_FINITE` `BLOCKED_PROPERTY` `INVALID_ARGUMENT` `INVALID_CONTEXT` `ASYNC_IN_SYNC` `HOST_ERROR` `HOST_CONTRACT` |
184
+ | `BonsaiTranslationError` | `UNTRANSLATABLE` (thrown by `toSQL` and `toMongo`) |
185
+
186
+ `try(expr, fallback)` in an expression catches runtime errors except `HOST_CONTRACT`; it never catches syntax, check, or limit errors.
187
+
188
+ ## Limits
189
+
190
+ Every limit except `timeout` is on by default; `limits` changes the budget. `0` disables `maxSteps` and `timeout`; the other limits must be positive integers.
191
+
192
+ | Limit | Default | Bounds |
193
+ |---|---|---|
194
+ | `maxSourceLength` | 100,000 | expression length |
195
+ | `maxDepth` | 128 | syntax nesting depth |
196
+ | `maxNodes` | 20,000 | syntax tree size, and the work of checking and compiling |
197
+ | `maxSteps` | 1,000,000 | work per evaluation, charged by the real cost of each operation |
198
+ | `maxStringLength` | 100,000 | strings an expression produces |
199
+ | `maxListLength` | 100,000 | lists an expression produces |
200
+ | `maxValueDepth` | 64 | nesting of values an expression builds or walks (cyclic data fails closed) |
201
+ | `maxPatternLength` | 4,096 | regular expression patterns passed to `matches` |
202
+ | `timeout` | none | wall-clock milliseconds per evaluation |
203
+
204
+ The step budget is deterministic: the same expression over the same data uses the same steps on every machine, and at the default budget any expression finishes or fails within about 100 ms on Node (about 150 ms for the slowest shapes we have found). Host functions are trusted code: limits cannot interrupt a synchronous host function that is already running, and a getter or Proxy you put in the context runs when it is read. Set a `timeout` when expressions call slow host functions. See [SECURITY.md](https://github.com/danfry1/bonsai-js/blob/main/SECURITY.md) and [docs/threat-model.md](https://github.com/danfry1/bonsai-js/blob/v1.0.0/docs/threat-model.md).
205
+
155
206
  ## Built-in functions
156
207
 
157
208
  All callable as `f(x, ...)` or `x.f(...)`.
@@ -159,12 +210,14 @@ All callable as `f(x, ...)` or `x.f(...)`.
159
210
  | Area | Functions |
160
211
  |---|---|
161
212
  | Text | `toUpperCase` `toLowerCase` `trim` `trimStart` `trimEnd` `startsWith` `endsWith` `includes` `indexOf` `lastIndexOf` `slice` `split` `replace` `replaceAll` `padStart` `padEnd` `repeat` `at` `matches` `toString` `toNumber` |
162
- | Lists | `map` `filter` `find` `findIndex` `some` `every` `none` `count` `flatMap` `reduce` `sort` `sortBy` `groupBy` `reverse` `unique` `flat` `first` `last` `join` `slice` `at` `includes` `indexOf` `isEmpty` |
213
+ | Lists | `map` `filter` `find` `findIndex` `some` `every` `none` `count` `flatMap` `reduce` `sort` `sortBy` `groupBy` `reverse` `unique` `flat` `first` `last` `join` `slice` `at` `includes` `indexOf` `lastIndexOf` `isEmpty` `min` `max` `sum` `avg` |
163
214
  | Numbers | `round` `floor` `ceil` `trunc` `abs` `sqrt` `clamp` `toFixed` `formatNumber` `formatCurrency` `min` `max` `sum` `avg` |
164
- | Maps | `keys` `values` `entries` `type` |
165
- | Time | `now` `timestamp` `weeks` `days` `hours` `minutes` `seconds` `milliseconds` `inDays` `inHours` `inMinutes` `inSeconds` `inMilliseconds` `year` `month` `day` `hour` `minute` `second` `dayOfWeek` `startOfDay` `startOfMonth` `startOfYear` `addDays` `addMonths` `addYears` `formatDate` |
215
+ | Maps and values | `keys` `values` `entries` `isEmpty` `type` `toString` |
216
+ | Time | `now` `timestamp` `duration` `weeks` `days` `hours` `minutes` `seconds` `milliseconds` `inDays` `inHours` `inMinutes` `inSeconds` `inMilliseconds` `year` `month` `day` `hour` `minute` `second` `dayOfWeek` `startOfDay` `startOfMonth` `startOfYear` `addDays` `addMonths` `addYears` `formatDate` |
166
217
 
167
- `matches(text, pattern)` uses JavaScript regular expression syntax (as with the `u` flag) and runs in linear time, so a user-written pattern cannot hang evaluation; backreferences, lookaround, named groups, `\p{...}`, and inline flags other than a leading `(?i)` are rejected ([full list](./docs/language.md)), a leading `(?i)` ignores ASCII case, and a pattern longer than `maxPatternLength` is a `PATTERN_LIMIT` error. `formatDate` understands `yyyy yy MMMM MMM MM M dd d EEEE EEE HH H hh h a mm m ss s SSS` and rejects unknown letters; constant patterns are checked when the expression is compiled.
218
+ Signatures and examples: [Built-in Functions](https://danfry1.github.io/bonsai-js/functions/).
219
+
220
+ `matches(text, pattern)` uses JavaScript regular expression syntax (as with the `u` flag) and runs in linear time, so a user-written pattern cannot hang evaluation; backreferences, lookaround, named groups, `\p{...}`, and inline flags other than a leading `(?i)` are rejected ([full list](https://danfry1.github.io/bonsai-js/language/functions#regular-expressions)), a leading `(?i)` ignores ASCII case, and a pattern longer than `maxPatternLength` is a `PATTERN_LIMIT` error. `formatDate` understands `yyyy yy MMMM MMM MM M dd d EEEE EEE HH H hh h a mm m ss s SSS` and rejects unknown letters; constant patterns are checked when the expression is compiled.
168
221
 
169
222
  `env.listFunctions()` returns every signature and description, for documentation or tooling.
170
223
 
@@ -178,8 +231,8 @@ import { bonsai } from 'bonsai-js'
178
231
  const rule = bonsai().compile('user.age >= 18 && user.plan == "pro"')
179
232
  const explanation = rule.explainSync({ user: { age: 25, plan: 'free' } })
180
233
 
181
- explanation.ok // true
182
- if (explanation.ok) explanation.value // false
234
+ explanation.ok // => true
235
+ explanation.ok ? explanation.value : explanation.error // => false
183
236
  console.log(String(explanation))
184
237
  // user.age >= 18 && user.plan == "pro" → false
185
238
  // ├─ user.age >= 18 → true
@@ -196,8 +249,7 @@ import { bonsai } from 'bonsai-js'
196
249
  const rule = bonsai().compile('user.age >= 18 && user.plan == "pro"')
197
250
  const user = { age: 16, plan: 'free' }
198
251
 
199
- rule.explainSync({ user }, { exhaustive: true }).reasons().map((r) => r.text)
200
- // ['user.age >= 18', 'user.plan == "pro"']
252
+ rule.explainSync({ user }, { exhaustive: true }).reasons().map((r) => r.text) // => ["user.age >= 18", "user.plan == \"pro\""]
201
253
  ```
202
254
 
203
255
  `explanation.trace` is the full tree: each node has an `id`, `kind`, `text`, `start`/`end` source offsets, `value` or `error`, and `children`. Parts skipped by short-circuiting are marked `evaluated: false`, and lambdas record each item they ran on under `iterations` (up to `maxIterations`, default 20; `maxTraceNodes` caps the whole trace). `JSON.stringify(explanation)` gives a bounded, cycle-safe snapshot that never runs getters; it records the whole value of each variable the rule reads, so for audit logs pass only the fields a rule needs (or log `JSON.stringify(explanation.reasons())`, which is bounded the same way). Evaluation errors are returned (`ok: false`, `error`) with the failing node marked, never thrown; invalid options or context throw, as with `evaluate()`. Like `evaluate()` and `evaluateSync()`, `explain()` returns a promise (and supports async host functions) and `explainSync()` returns the explanation directly. Explaining uses a separately compiled program, so ordinary evaluation is not slowed down.
@@ -212,13 +264,14 @@ import { bonsai } from 'bonsai-js'
212
264
  const rule = bonsai().compile('user.plan == "pro" && order.total > limits.minTotal')
213
265
  const limits = { minTotal: 100 }
214
266
 
215
- rule.partial({ user: { plan: 'free' }, limits }) // { status: 'value', value: false }
267
+ rule.partial({ user: { plan: 'free' }, limits }) // => { status: "value", value: false }
216
268
 
217
269
  const result = rule.partial({ user: { plan: 'pro' }, limits })
218
270
  if (result.status === 'residual') {
219
- result.source // 'order.total > 100'
220
- result.dependsOn // ['order.total']
221
- result.evaluateSync({ order: { total: 150 } }) // true, the same answer as with all the data
271
+ result.source // => 'order.total > 100'
272
+ result.dependsOn // => ["order.total"]
273
+ // The same answer as with all the data:
274
+ result.evaluateSync({ order: { total: 150 } }) // => true
222
275
  }
223
276
  ```
224
277
 
@@ -237,12 +290,11 @@ import { toMongo, toSQL } from 'bonsai-js/query'
237
290
  const filter = bonsai().compile('order.status == "paid" && order.total > minTotal')
238
291
  const columns = { status: 'text', total: 'number' } as const
239
292
 
240
- toSQL(filter, { row: 'order', columns, dialect: 'postgres', known: { minTotal: 100 } })
241
- // { sql: '((("status" COLLATE "C") = ($1::text COLLATE "C")) AND ("total"::float8 > $2::float8))',
242
- // params: ['paid', 100] }
293
+ const query = toSQL(filter, { row: 'order', columns, dialect: 'postgres', known: { minTotal: 100 } })
294
+ query.sql // => '((("status" COLLATE "C") = ($1::text COLLATE "C")) AND ("total"::float8 > $2::float8))'
295
+ query.params // => ["paid", 100]
243
296
 
244
- toMongo(filter, { row: 'order', fields: columns, known: { minTotal: 100 } })
245
- // { filter: { $and: [{ status: { $eq: 'paid' } }, { total: { $gt: 100 } }] }, options: { collation: { locale: 'simple' } } }
297
+ toMongo(filter, { row: 'order', fields: columns, known: { minTotal: 100 } }) // => { filter: { $and: [{ status: { $eq: "paid" } }, { total: { $gt: 100 } }] }, options: { collation: { locale: "simple" } } }
246
298
  ```
247
299
 
248
300
  The query selects exactly the records for which the filter evaluates to `true` in Bonsai, including null handling (SQL's three-valued logic is made two-valued) and records where the filter would fail (they are excluded). One exception: in Postgres, a record whose arithmetic overflows a double (or whose nonzero product rounds to zero) makes the whole query fail with `value out of range`, an error rather than a different set of rows, where SQLite excludes the record; keep stored numbers within range to avoid it (see the query guide's caveats). Only declared columns can be queried, values are always parameters, and anything without an exact database equivalent throws a `BonsaiTranslationError` pointing at it. This is checked by differential tests against real SQLite, Postgres, and a MongoDB query engine.
@@ -255,9 +307,9 @@ The query selects exactly the records for which the filter evaluates to `true` i
255
307
  import { bonsai, print } from 'bonsai-js'
256
308
 
257
309
  const tree = bonsai().parse('trim(name).toUpperCase()')
258
- print(tree) // 'trim(name).toUpperCase()'
259
- print(tree, { calls: 'method' }) // 'name.trim().toUpperCase()'
260
- print(tree, { calls: 'function' }) // 'toUpperCase(trim(name))'
310
+ print(tree) // => 'trim(name).toUpperCase()'
311
+ print(tree, { calls: 'method' }) // => 'name.trim().toUpperCase()'
312
+ print(tree, { calls: 'function' }) // => 'toUpperCase(trim(name))'
261
313
  ```
262
314
 
263
315
  `f(x)` and `x.f()` produce the same call node, so a builder can show any chain as a list of steps and choose how to print it. `env.listFunctions()` provides the palette (names, signatures, descriptions), and the language service provides the types at any position for dropdowns. `forEachChild` walks a tree, `mapChildren` rewrites one, and `env.check(source).typeOf(node)` gives the type of any checked node. Comments and spacing are not part of the tree, so printing drops them; to change a saved rule and keep its comments, edit the source at the spans the parser records (`nameStart` and `nameEnd` cover a property or function name). See the [syntax tree reference](https://danfry1.github.io/bonsai-js/api/syntax-tree).
@@ -276,37 +328,6 @@ service.diagnostics('user.agee') // same diagnostics as env.check
276
328
 
277
329
  The service never evaluates expressions or calls host functions.
278
330
 
279
- ## Errors
280
-
281
- Every error thrown while checking or evaluating an expression is a `BonsaiError` with a stable `code`, the `source`, a `span` (`{ start, end }`), a 1-based `position`, and a `formatted` message with a code frame. That includes failures in your own code: a host function that throws, or a getter or Proxy in the context that throws when read, becomes a `HOST_ERROR` with the original error as its `cause`. Invalid configuration passed to `bonsai()` or `fn()`, and a syntax tree `print()` cannot print faithfully, throw a `TypeError` or `RangeError` immediately; invalid per-evaluation options do too from `evaluateSync()`, and make `evaluate()` reject.
282
-
283
- | Class | Codes |
284
- |---|---|
285
- | `BonsaiSyntaxError` | `SYNTAX` |
286
- | `BonsaiCheckError` | `CHECK` (see `.diagnostics`) |
287
- | `BonsaiLimitError` | `SOURCE_TOO_LONG` `TOO_DEEP` `TOO_MANY_NODES` `TOO_COMPLEX` `STEP_LIMIT` `STRING_LIMIT` `LIST_LIMIT` `VALUE_DEPTH_LIMIT` `PATTERN_LIMIT` `TIMEOUT` `ABORTED` |
288
- | `BonsaiRuntimeError` | `TYPE_ERROR` `NO_OVERLOAD` `NULL_RECEIVER` `DIVISION_BY_ZERO` `NON_FINITE` `BLOCKED_PROPERTY` `INVALID_ARGUMENT` `INVALID_CONTEXT` `ASYNC_IN_SYNC` `HOST_ERROR` `HOST_CONTRACT` |
289
-
290
- `try(expr, fallback)` in an expression catches runtime errors except `HOST_CONTRACT`; it never catches syntax, check, or limit errors.
291
-
292
- ## Limits
293
-
294
- All limits are on by default; `limits` changes the budget. `0` disables `maxSteps` and `timeout`; the other limits must be positive integers.
295
-
296
- | Limit | Default | Bounds |
297
- |---|---|---|
298
- | `maxSourceLength` | 100,000 | expression length |
299
- | `maxDepth` | 128 | syntax nesting depth |
300
- | `maxNodes` | 20,000 | syntax tree size, and the work of checking and compiling |
301
- | `maxSteps` | 1,000,000 | work per evaluation, charged by the real cost of each operation |
302
- | `maxStringLength` | 100,000 | strings an expression produces |
303
- | `maxListLength` | 100,000 | lists an expression produces |
304
- | `maxValueDepth` | 64 | nesting of values an expression builds or walks (cyclic data fails closed) |
305
- | `maxPatternLength` | 4,096 | regular expression patterns passed to `matches` |
306
- | `timeout` | none | wall-clock milliseconds per evaluation |
307
-
308
- The step budget is deterministic: the same expression over the same data uses the same steps on every machine, and at the default budget any expression finishes or fails within about 100 ms on Node (about 150 ms for the slowest shapes we have found). Host functions are trusted code: limits cannot interrupt a synchronous host function that is already running, and a getter or Proxy you put in the context runs when it is read. Set a `timeout` when expressions call slow host functions. See [SECURITY.md](./SECURITY.md) and [docs/threat-model.md](./docs/threat-model.md).
309
-
310
331
  ## Stability
311
332
 
312
333
  Bonsai follows Semantic Versioning. The [stability policy](https://danfry1.github.io/bonsai-js/guide/stability) says what a minor release may change (new syntax, functions, fields, and union variants, messages, step costs) and what is not public API (error constructors, hand-built host functions).
@@ -0,0 +1,17 @@
1
+ import { Type } from "./types.mjs";
2
+ import { ContextFor, Environment, EnvironmentOptions, Library, NoVariables } from "./environment.mjs";
3
+ //#region src/bonsai.d.ts
4
+ /**
5
+ * Creates an environment: the variables, functions, and limits expressions are
6
+ * checked and evaluated against.
7
+ *
8
+ * ```ts
9
+ * const env = bonsai({ variables: { user: t.object({ age: t.number() }) } })
10
+ * const adult = env.compile('user.age >= 18', { expect: t.boolean() })
11
+ * adult.evaluateSync({ user: { age: 30 } }) // true
12
+ * ```
13
+ */
14
+ export declare function bonsai<const V extends Readonly<Record<string, Type>> = NoVariables, const L extends readonly Library[] = [], const S extends boolean | undefined = undefined>(options?: EnvironmentOptions<V, L> & {
15
+ readonly strict?: S;
16
+ }): Environment<ContextFor<V, L, S>>;
17
+ //#endregion
@@ -0,0 +1,20 @@
1
+ import { environmentWith } from "./environment.mjs";
2
+ import { explainAndPartial } from "./features.mjs";
3
+ //#region src/bonsai.ts
4
+ /**
5
+ * Creates an environment: the variables, functions, and limits expressions are
6
+ * checked and evaluated against.
7
+ *
8
+ * ```ts
9
+ * const env = bonsai({ variables: { user: t.object({ age: t.number() }) } })
10
+ * const adult = env.compile('user.age >= 18', { expect: t.boolean() })
11
+ * adult.evaluateSync({ user: { age: 30 } }) // true
12
+ * ```
13
+ */
14
+ function bonsai(options = {}) {
15
+ return environmentWith(explainAndPartial, options);
16
+ }
17
+ //#endregion
18
+ export { bonsai };
19
+
20
+ //# sourceMappingURL=bonsai.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bonsai.mjs","names":[],"sources":["../src/bonsai.ts"],"sourcesContent":["import {\n environmentWith,\n type ContextFor,\n type Environment,\n type EnvironmentOptions,\n type Library,\n type NoVariables,\n} from './environment.js'\nimport { explainAndPartial } from './features.js'\nimport type { Type } from './types.js'\n\n/**\n * Creates an environment: the variables, functions, and limits expressions are\n * checked and evaluated against.\n *\n * ```ts\n * const env = bonsai({ variables: { user: t.object({ age: t.number() }) } })\n * const adult = env.compile('user.age >= 18', { expect: t.boolean() })\n * adult.evaluateSync({ user: { age: 30 } }) // true\n * ```\n */\nexport function bonsai<\n const V extends Readonly<Record<string, Type>> = NoVariables,\n const L extends readonly Library[] = [],\n const S extends boolean | undefined = undefined,\n>(\n options: EnvironmentOptions<V, L> & { readonly strict?: S } = {},\n): Environment<ContextFor<V, L, S>> {\n return environmentWith<ContextFor<V, L, S>>(explainAndPartial, options)\n}\n"],"mappings":";;;;;;;;;;;;;AAqBA,SAAgB,OAKd,UAA8D,CAAC,GAC7B;CAClC,OAAO,gBAAqC,mBAAmB,OAAO;AACxE"}
@@ -747,7 +747,8 @@ function analyzeWithin(root, env, options) {
747
747
  if (quiet) return isExact(objectType) ? NULL : ANY;
748
748
  const suggestion = suggest(name, () => Object.keys(objectType.fields));
749
749
  const span = nameSpan(at, name);
750
- report("UNKNOWN_PROPERTY", `Property "${name}" does not exist on ${typeText(objectType)}${didYouMean(suggestion)}`, span ?? at, "error", span === void 0 ? void 0 : suggestion);
750
+ const receiver = at.type === "Member" || at.type === "Index" ? pathText(at.object) : void 0;
751
+ report("UNKNOWN_PROPERTY", `Property "${name}" does not exist on ${receiver ?? typeText(objectType)}${didYouMean(suggestion)}`, span ?? at, "error", span === void 0 ? void 0 : suggestion);
751
752
  return ANY;
752
753
  }
753
754
  if ((objectType.kind === "list" || objectType.kind === "string" || objectType.kind === "literal" && typeof objectType.value === "string") && name === "length") return NUMBER;
@@ -996,8 +997,8 @@ function analyzeWithin(root, env, options) {
996
997
  const suggestion = alias === void 0 ? suggest(node.name, () => env.functionNames()) : /"(?<name>[^"]+)"/u.exec(alias)?.groups?.name;
997
998
  const hint = alias === void 0 ? didYouMean(suggestion) : `; ${alias}`;
998
999
  report("UNKNOWN_FUNCTION", `Unknown function "${node.name}"${hint}`, {
999
- start: node.nameStart,
1000
- end: node.nameEnd
1000
+ start: node.nameStart ?? node.start,
1001
+ end: node.nameEnd ?? node.end
1001
1002
  }, "error", suggestion);
1002
1003
  for (const arg of node.args) if (arg.type === "Lambda") checkLambda(arg, [], scope);
1003
1004
  return ANY;
@@ -1578,6 +1579,25 @@ function typeText(type) {
1578
1579
  if (text === void 0) typeTexts.set(type, text = formatType(type, MESSAGE_TYPE_TEXT));
1579
1580
  return text;
1580
1581
  }
1582
+ /** Longest receiver path a diagnostic message names instead of its type. */
1583
+ const MESSAGE_PATH_TEXT = 60;
1584
+ /**
1585
+ * A receiver as the expression reads it (`customer.address`), for messages
1586
+ * people who write rules can follow; undefined unless it is a plain path of
1587
+ * names that fits in a message.
1588
+ */
1589
+ function pathText(node) {
1590
+ let suffix = "";
1591
+ let current = node;
1592
+ while (current.type === "Member") {
1593
+ suffix = `${current.optional ? "?." : "."}${current.name}${suffix}`;
1594
+ if (suffix.length > MESSAGE_PATH_TEXT) return void 0;
1595
+ current = current.object;
1596
+ }
1597
+ if (current.type !== "Variable" && current.type !== "Local") return void 0;
1598
+ const text = `${current.name}${suffix}`;
1599
+ return text.length > MESSAGE_PATH_TEXT ? void 0 : text;
1600
+ }
1581
1601
  /**
1582
1602
  * "Did you mean" hints cost an edit distance per candidate, so a source full of
1583
1603
  * typos against a large schema is bounded: hints for the first unknown names