solve-engine 2.0.0
The first major release. The API is reshaped around three things a 1.x caller kept having to work around: a constructor that made you spell out every slot, a result that could not tell a fault from a real zero, and an engine that pulled every package into your bundle whether you used it or not. Each change names the boundary it deliberately does not cross, and the whole surface is smaller to parse than 1.x was.
The constructor takes an options object
ExpressionEngine had five positional parameters, so a call that only wanted to pass a package list still had to spell out every slot before it. It now takes a single EngineOptions object, and every field is optional.
// before
new ExpressionEngine("en", false, undefined, undefined, [ARITHMETIC_PACKAGE]);
// now
new ExpressionEngine({ packages: [ARITHMETIC_PACKAGE] });The fields are locale, packages, config and diagnostics. config takes an EngineConfigOverride, a per-section partial merged over the defaults, so overriding one validation limit no longer means restating a whole config section. The fourth positional slot, an internal diagnostic-pipeline injection point no consumer set, is gone. createEngine, fromJSON and the worker runtime take the same shape.
A result is a Value, and a fault says so
evaluateLine and evaluateExpression returned a single-element Value[], an array kept only for API stability. They now return the Value itself.
// before
const [value] = engine.evaluateExpression("2 + 2 * 10");
// now
const value = engine.evaluateExpression("2 + 2 * 10"); // value.toNumber() === 22An Error or a Pending value used to read as the number 0 through toNumber(), so a caller that reached for the number could not tell a fault apart from a real zero. Value now carries the guards the engine already used internally.
expression result
5 kg to m isError() → true, errorCode → the conversion error
live price of silver isPending() → true
2 + 2 isFault() → false
isError(), isPending(), isFault() and the errorCode / errorMessage accessors make the distinction the engine makes, and evaluateNumber applies it too: an impossible conversion returns NaN rather than the 0 that toNumber() would have handed back. evaluateLineDetailed, LineEvaluation and EvalResults are removed, and the off-thread worker client's evaluateExpression collapses the same way, from Promise<SerializedValue[]> to Promise<SerializedValue>.
Packages are explicit, and the engine tree-shakes
The constructor registered all built-in packages by default, so importing the engine pulled every package into a consumer's bundle whether they used it or not. It now registers only the packages it is given, so a bundler drops every built-in a consumer never imports.
| a consumer importing and constructing the engine | parsed |
|---|---|
| before | 475 KB (all 25 packages, always) |
| now, arithmetic only | 352 KB |
This is a breaking change: new ExpressionEngine() with no packages now registers nothing, so 2 + 2 on a bare engine is an undefined-token parse error rather than 4. For the common "I want everything" case, createEngine() is batteries-included; for a slimmer engine, pass the packages you want and import them from solve-engine/packages.
import { createEngine } from "solve-engine";
const engine = createEngine(); // the full built-in set
import { ExpressionEngine } from "solve-engine";
import { ARITHMETIC_PACKAGE, UOM_PACKAGE } from "solve-engine/packages";
const slim = new ExpressionEngine({ packages: [ARITHMETIC_PACKAGE, UOM_PACKAGE] });fromJSON takes the same packages argument and must be given the set the snapshot was taken with, since a snapshot's compiled bytecode only lines up against the packages present when it was written.
Package descriptors are keyed by token and name
If you author a package, its parselets and plugin functions were declared as arrays of wrapper objects, and every plugin function carried a numeric index you minted by hand and threaded into both the descriptor and the parselet that emitted it. Both are now keyed records, and the engine owns the index.
// before
const LIGHTEN_FN_IDX = allocatePluginFunctionIndex();
prefixParselets: [{ tokenType: "COLOUR_CALL", parselet: new ColourCallParselet() }],
pluginFunctions: [{ index: LIGHTEN_FN_IDX, handler: lightenHandler }],
// in the parselet: emitOpcode(CALL_PLUGIN); emitIndex(LIGHTEN_FN_IDX); emitIndex(argCount)
// now
prefixParselets: { COLOUR_CALL: new ColourCallParselet() },
pluginFunctions: { lighten: lightenHandler },
// in the parselet: builder.emitPluginCall("lighten", argCount)The old shape leaked a process-global index counter into every author's code and made a class of silent mistakes possible: two functions sharing an index, a parselet emitting an index its descriptor never registered, an index registered but never emitted. Naming the function once and letting the engine own the index removes them; a name a parselet emits that no descriptor declares is now a registration-time error, not a silent mis-dispatch. A plugin-function name two packages share is a checkPackageCompatibility warning, the later registration winning, exactly as the other cross-package collisions already are.
The boundary: an async resolver that scans compiled bytecode still works in numeric indices, because that is what bytecode is. It recovers its own function's index by the qualified name the engine files it under, pluginFunctionIndexFor("<package>:<name>"), rather than owning a constant. The examples/osrs Grand Exchange resolver is the worked example.
Removed long-dead surface
Three groups of exports that registered into state nothing evaluated against, or duplicated a canonical one, are gone. IEnginePackage.variableSources (with IVariableSource, VariableResolver, IPackageRegistry.registerVariableSource and the solve-engine/variables subpath): a source's variables were registered into a resolver no evaluation path ever queried, so they were never found; a package that needs to expose a value contributes a pluginFunctions entry instead. The PackageRegistry class, its packageRegistry singleton and the IPackageRegistry interface: they wrote into process-wide state no engine reads, so register on an engine instead. And symbolToCurrency, a backward-compatibility re-export of the currency-symbol alias table that lives in uom/CurrencyAliases.ts.
A smaller bundle
The build shipped unminified, so a consumer without their own bundler parsed the full source, whitespace and all, on every load. The build now minifies, the unit table is stored packed and decoded once at load rather than as 1,456 repeating entries, and the bundled semver (pulled in whole for a single compatibility check) is replaced by a small internal range checker covering the grammar a package's engineVersion actually uses.
| importing the whole engine | parsed, minified |
|---|---|
| 1.x | 1,263 KB |
| 2.0 | 480 KB |
Nothing a consumer computes changes. Source maps stay on, so a production stack trace still points at real source; the packed unit table is asserted at generation time to decode to exactly the source table, so a packing bug fails the build rather than altering a conversion; and package gating is unchanged, a 0.x caret still narrows to the minor and a malformed range is still a distinct invalid-range error.
Verification
- The whole engine suite runs against the new API: 7,790 tests in 342 suites, no failures, including the options-object construction, the bare-value return, the fault guards, the explicit-packages tree-shaking contract (a bare engine registers nothing, and
createEnginehas its own spec), and every built-in package's descriptor and parselet-emit migrated to the keyed-record shape. - Tree-shaking is measured directly: a consumer importing
ExpressionEngineplus one package bundles 123 KB smaller than one importingBUILTIN_PACKAGES, and the bundled-consumer contract confirms nosemveridentifier reaches the shipped bundle. npm run verify(typecheck,test:ci, build, smoke, the bundled-consumer contract) is green, alongsidelint,lint:docs,lint:commentsandlint:size. The publish itself runsassert-release-tag,verifyandtest:consumeragainst the packed, minified tarball, on both the ESM and CJS builds, before anything reaches the registry.