Skip to content

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.

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 evaluation
value.value; // "currency:USD:GBP", the query key

Returning zero, or the last known rate, would be a confidently wrong answer, which is the failure mode the engine works hardest to avoid.

  1. 1budget = 2400 USD$2400.00
  2. 2in_gbp = budget to GBPpending
  3. 3half = in_gbp / 2pending

The conversion needs a rate that has not arrived. The engine returns a value whose type is pending, carrying the key of the query it is waiting on, rather than blocking or guessing.

Live data

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.

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.

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 fetch
await engine.settle({ timeoutMs: 5_000 });
engine.evaluateExpression("10 USD to GBP"); // the converted amount

It 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’s toResolveTo runs that loop within one deadline (see testing a package).
  • It does not outlast clear(). Clearing the engine discards what was in flight, and a waiting settle() 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.

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
});

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.

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.
}

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.

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:

FieldWhat it says
providerWho 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.
kindlive (fetched when the line ran), primed (handed over by the host), or historical (a figure for a named past day).
fetchedAtWhen the engine received it, in epoch milliseconds; for a primed table, the publishedAt the host gave.
subjectWhat was asked for, when the provider answers more than one question: a currency pair (USD/GBP), a ticker, a place.
asOfFor a historical figure, the day it describes.
frozenAtPresent 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.

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"); // 1

A 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.

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.

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.