This site describes solve-engine as it is on main: 2.43.0, which npm does not have yet. npm installs 2.40.0, so a page may show an answer that version does not give yet.
Async and live data
Some expressions cannot be answered immediately because they depend on data from the network. Currency conversion, weather and stock lookups are the built-in cases. This page is about consuming them from TypeScript.
Pending is a value
Section titled “Pending is a value”Rather than blocking or returning a placeholder, the engine returns a value
whose type is Pending. It carries the key of the query it is waiting on.
import { ValueType } from "solve-engine/vm";
const value = engine.evaluateExpression("10 USD to GBP");value.type === ValueType.Pending; // true, on the first evaluationvalue.value; // "currency:USD:GBP", the query keyReturning zero, or the last known rate, would be a confidently wrong answer, which is the failure mode the engine works hardest to avoid.
Waiting for the answer
Section titled “Waiting for the answer”The engine starts the fetch in the background and records that the line depends on it. When the data lands it does not push the new value at you; it tells you which lines changed, and you re-evaluate them.
getEventStream() is that notification, as a standard ReadableStream. Read it
with await:
import { createEngine } from "solve-engine";
const engine = createEngine({ locale: "en" });const reader = engine.getEventStream().getReader();
// First evaluation: pending, no rate yet.let value = engine.evaluateExpression("10 USD to GBP");
// Wait for the fetch to land, then evaluate the same line again.const { value: event } = await reader.read();if (event?.type === "lines-updated") { value = engine.evaluateExpression("10 USD to GBP"); value.type; // ValueType.Uom now, not Pending value.toNumber(); // the converted amount}A lines-updated event carries lineNumbers (which lines to re-evaluate) and
affectedQueryKeys (what resolved). In a document you re-evaluate the lines it
names rather than the whole thing.
Reacting continuously
Section titled “Reacting continuously”An editor does not read one event and stop. It keeps a loop, and each event is a signal to recompute the lines that changed and repaint them.
async function watch(engine: ExpressionEngine, onChange: (lines: number[]) => void) { const reader = engine.getEventStream().getReader(); while (true) { const { value: event, done } = await reader.read(); if (done) break; if (event.type === "lines-updated") onChange(event.lineNumbers); }}Because the stream applies backpressure, a slow consumer cannot be flooded: the engine buffers up to a limit and waits rather than growing without bound.
Waiting for every value to settle
Section titled “Waiting for every value to settle”An editor reacts to each event as it arrives. A script, a test or a server that
answers one request has nothing to repaint: it wants the settled answer and is
happy to wait for it. settle() is that wait. It resolves once no fetch the
engine started is still in flight and the lines those fetches fed have been
evaluated again, so the next evaluation gives the settled value.
const engine = createEngine();
engine.evaluateExpression("10 USD to GBP"); // Pending: this starts the fetchawait engine.settle({ timeoutMs: 5_000 });engine.evaluateExpression("10 USD to GBP"); // the converted amountIt rejects rather than hands back a Pending line as if it were settled: at the
deadline it rejects with the coded error SETTLE_TIMEOUT, whose context says how
many values were still in flight, and a timeoutMs that is not a finite number
of zero or more is refused with SETTLE_TIMEOUT_INVALID. The deadline is 10,000
ms when none is given. With nothing in flight it resolves at once.
What it deliberately does not do:
- It starts nothing. It waits for the fetches an evaluation has already started, so evaluate first. It does not wait for a background refresh or its next tick, which by design never finishes.
- It never evaluates. A line whose first fetch reveals a second one needs
another evaluation and another
settle(). The testing kit’stoResolveToruns that loop within one deadline (see testing a package). - It does not outlast
clear(). Clearing the engine discards what was in flight, and a waitingsettle()resolves then. A fetch that lands after the clear is dropped, so it cannot re-run a line of the next document.
The worker client has the same call (await worker.settle()), and its settled
values arrive through onResolved as ever.
Refreshing on a schedule
Section titled “Refreshing on a schedule”By default a live value refreshes only when its line is re-evaluated: staleTime
marks it stale after a while, and the next keystroke refetches it. A note left
open, showing a quote or a rate, holds whatever it last resolved.
Proactive background refresh closes that gap. It is off by default, because it needs timers and a live editor consuming the stream above; a headless or batch host leaves it off. Turn it on when you construct the engine:
const engine = createEngine({ config: { backgroundRefresh: { enabled: true } } });A value refreshes in the background only if its resolver declares a cadence.
refetchIntervalMs sits beside staleTimeMs and is independent of it: a live
quote might refetch every minute, an FX rate every few minutes, an immutable
historical close never.
const stocks = createStocksPackage({ fetchQuote: (ticker, signal) => myQuoteService(ticker, signal), // your source, resolving to a StockQuote refetchIntervalMs: 60_000, // refresh an on-screen quote once a minute});Switching live data off
Section titled “Switching live data off”By default a city name or a currency pair in a line fetches live data from a public endpoint as soon as the line evaluates. A host that must not make outbound requests (an offline document, a sandboxed evaluation, a tenant with egress rules) switches the network off when it constructs the engine:
const engine = createEngine({ config: { network: { enabled: false } } });The engine is otherwise unchanged. Every live-data form answers with a
NETWORK_DISABLED error that names the setting instead of making a request, so
weather in London and 100 USD in GBP both read live data is switched off
for this engine (network.enabled is false). The reader learns it is policy,
not an outage.
Two things keep working with the switch off, because neither reaches a network.
Rates a host primes by hand (currencyExchangeService.primeRates) still
convert, which is what an offline host with its own rate table wants. And a
global variable still waits for the line that declares it: the resolver behind
global :name reads engine state, and declares itself local (see writing an
async data source).
The gate closes before any request is made. A package’s async resolver is not consulted at all, so nothing it would have fetched is started. The one boundary is a plugin function that returns a promise directly rather than going through an async resolver: such a function has already run by the time the engine sees its promise, so the engine refuses the result but cannot recall a request the function started. The documented shape for live data is the resolver, and every built-in package uses it.
The fresh value arrives on the same event stream as everything else, a
lines-updated event for the lines that changed, so the watch loop above needs
no changes. It is the push counterpart of the pull path, not a replacement: both
run off the one stream, and staleTime still governs re-evaluation underneath.
The boundaries are deliberate. Only values currently on screen refresh; a value whose line the reader has edited away stops at once, so an open note leaks no timers or requests. A refetch still running when the next is due is skipped rather than stacked, and a failed one is swallowed (the pull path surfaces the failure on the next re-evaluation). A value refreshes only if its resolver asked to, so enabling the feature does not put every live value on a timer.
When a fetch fails
Section titled “When a fetch fails”A failed lookup is its own event rather than a thrown error, so one dead request does not break the loop.
if (event.type === "error") { console.warn(`${event.queryKey} failed:`, event.error.message); // The line stays pending; decide whether to retry or show the failure.}When it keeps failing
Section titled “When it keeps failing”A resolver that never becomes ready would otherwise be a loop with nothing at the end of it: the engine reports the line changed, you re-evaluate it, the resolver starts another failing fetch, and round it goes.
The engine ends that for you. After three consecutive failures for the same query, the value stops being announced, so your loop stops being told to re-evaluate that line and the fetching stops. Editing the line clears the count and tries again, and so does a success, so an outage followed by a recovery is not held against the value.
Every step of that round trip is a microtask, which matters more than it sounds:
the microtask queue drains completely before a single timer runs, so a loop like
this can starve every setTimeout in your process while it spins. The engine
hands a flush to the macrotask queue once several have chained without the event
loop getting a turn, so your timers and deadlines keep running even while values
are still settling.
Neither of these is a reason to skip your own handling. Watch for
event.type === "error" and show the reader something: the engine’s job is to
stop the machine spinning, and yours is to say what happened.
Where a live value came from
Section titled “Where a live value came from”A live figure is true at one moment according to one provider, and the number
alone says neither. Every value that depends on one carries the record in
value.sources: a list with one entry per live figure behind it, so a host can
show “reference rate, 23 Sep 16:02” beside the line.
import { createEngine, formatValue } from "solve-engine";import { currencyExchangeService } from "solve-engine/uom";
currencyExchangeService.primeRates("USD", { GBP: 0.741 }, { provider: "Treasury feed", publishedAt: Date.parse("2026-09-23T16:02:00Z"),});
const engine = createEngine({ locale: "en" });const total = engine.evaluateExpression("(10 USD in GBP) * 3");formatValue(total); // "= £22.23"total.sources;// [{ provider: "Treasury feed", kind: "primed", fetchedAt: 1790179320000, subject: "USD/GBP" }]A primed table sits beside the tables the engine fetches for itself rather than replacing them, and each source keeps its own: Frankfurter’s fiat rates, CoinGecko’s crypto prices and a host’s primed rates for one base currency can all be fresh at once, so a note converting dollars to euros and dollars to bitcoin converts both, and a live fetch never discards the pairs a host primed. Where two fresh tables for one base both hold a pair, the one stored most recently serves it, which is the rate replacing the older table used to give; priming the same provider and base again replaces that provider’s own table. The record names the table that actually served each conversion.
Each record is plain data, so it crosses the worker boundary (the worker DTO’s
sources field) and a snapshot unchanged:
| Field | What it says |
|---|---|
provider | Who supplied the figure: Frankfurter or CoinGecko for the rates the engine fetches itself, Open-Meteo for weather, and for a host’s own data the name it gave (primeRates’s provider, a package’s provider option), or host when it gave none. |
kind | live (fetched when the line ran), primed (handed over by the host), or historical (a figure for a named past day). |
fetchedAt | When the engine received it, in epoch milliseconds; for a primed table, the publishedAt the host gave. |
subject | What was asked for, when the provider answers more than one question: a currency pair (USD/GBP), a ticker, a place. |
asOf | For a historical figure, the day it describes. |
frozenAt | Present once the figure is part of a frozen answer: when that answer was frozen. See the next section. |
The record is set where a live figure enters the engine and carried by everything
computed from it: arithmetic, conversions, rounding and the other built-in
functions, aggregates such as total of #tag, and every line or variable that
reads the value. Two records for the same figure merge into one. A value built
only from what the reader typed carries nothing, and sources is undefined.
The boundaries are deliberate. A comparison’s true or false, a bracketed list
built from live values, and text made from one (as fraction) do not carry a
record; text a provider returns (weather in London) does. An Error or a Pending
value is not a figure and carries none. And the engine records facts, not
judgements: it says where a rate came from and when, and leaves deciding whether
that is recent enough to the host.
Keeping an answer fixed
Section titled “Keeping an answer fixed”A reader who ends a line with frozen asks for its first answer to be kept (see
frozen answers). The value that line answers with
carries a frozen mark, the moment it was fixed and the key it is stored under,
and its sources records gain frozenAt:
const line = engine.evaluateLine(1, "10 USD in GBP frozen");formatValue(line); // "= £7.41"line.frozen; // { at: 1790255072178, key: "10 USD in GBP" }line.sources;// [{ provider: "Treasury feed", kind: "primed", fetchedAt: 1790179320000,// subject: "USD/GBP", frozenAt: 1790255072178 }]The engine keeps the answer and answers the line from it on every later
evaluation, on every path (a first pass, a re-run from cached bytecode, the re-run
when a value lands), without running the line: no rate is read and no request is
made. The line also stops being one of its source’s readers, so a value landing
no longer re-runs it, and a background refresh that no other line reads stops on
its next tick. getFrozenValues() lists what is kept, with the day each answer was frozen
in the engine’s calendar, and unfreeze(key) forgets one (unfreeze() forgets
them all), so its line freezes afresh the next time it is evaluated.
engine.getFrozenValues();// [{ key: "10 USD in GBP", value, at: 1790255072178, day: "2026-09-24" }]engine.unfreeze("10 USD in GBP"); // 1A frozen answer lives in the engine that froze it, so an app that wants a note to
read the same next month keeps it: toJSON() writes the answers to the
snapshot’s frozen field and carries the frozen lines and the variables they
define, and fromJSON() restores them, so the restored document answers those
lines with the network switched off (see
snapshotting). clear()
forgets them with the rest of the document.
A line can also name its day, frozen on 2026-09-24, and then refuses with a
FROZEN_VALUE_MISSING error in an engine that holds no value frozen that day,
rather than freezing a new one. That is how the text itself carries the claim,
for a note shared without its snapshot. An app that wants every frozen line to
say so writes the day back once the line has frozen, reading it from the store so
it is the day the engine recorded:
const value = engine.evaluateLine(n, text);if (value.frozen && !/\bfrozen on\b/.test(text)) { const { day } = engine.getFrozenValues().find((r) => r.key === value.frozen!.key)!; replaceLine(n, text.replace(/\bfrozen\s*$/, `frozen on ${day}`));}Freezing belongs to one engine. Two documents, or two engines, keep their own answers, and nothing is shared between them unless the app carries a snapshot across.
Cleaning up
Section titled “Cleaning up”A pending value keeps the background fetch, and the batcher behind it, reachable.
Dropping your reference to the engine is not enough to release them. Call
clear() when you are finished, or a long-lived process leaks the work of every
document it has seen.
engine.clear();In Node this is also what lets the process exit: an engine with live async work outstanding keeps the event loop alive until it is cleared.
Supplying your own data source
Section titled “Supplying your own data source”The built-in resolvers are currency, weather and stocks. To make the engine resolve something else, currency rates from your own service, prices from your own API, you write an async resolver. See writing an async data source.