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.

Writing an async data source

The built-in async lookups are currency, weather and stocks. To resolve something else, exchange rates from your own service, prices from your own API, a value from a database, you register an async resolver.

This is the async half of a package. It assumes you have read writing a package, because a data source needs two things: a piece of syntax that triggers it (a symbol, a function name, a phrase) and a resolver that fetches the data. This page is the resolver.

Most data sources are a question and a fetch: a word on the line names what to look up, and a request answers it. createQueryResolver builds the resolver for that shape and the plugin function that reads its answers back, as a pair, so the package supplies only the fetch. It handles the parts every live lookup needs: the bytecode scan that finds the question, the cache and its expiry, a timeout, a cooldown after a failure, how many requests run at once, and the record of where each value came from.

A complete package, where fxrate EUR asks your own service for a rate:

import type { IEnginePackage } from "solve-engine";
import { createQueryResolver } from "solve-engine/resolvers";
import { OpCode, type PrefixParselet } from "solve-engine/parser";
import { uomValue } from "solve-engine/vm";
// `fxrate EUR` asks your own service for today's rate into euros.
const { resolver, pluginFunction } = createQueryResolver({
namespace: "myrates",
packageName: "my-rates",
functionName: "fxrate",
fetchQuery: async (currency, signal) => {
const response = await fetch(`https://example.com/rate/${currency}`, { signal });
const body = (await response.json()) as { value: number };
return uomValue(body.value, currency);
},
});
// The syntax: `fxrate` then a currency code, compiled to a call the resolver answers.
const fxrateParselet: PrefixParselet = {
category: "Rates",
parse(parser, _token, builder) {
const currency = parser.consume();
builder.emitOpcode(OpCode.PUSH_STRING);
builder.emitString(String(currency.value));
builder.emitPluginCall("fxrate", 1);
},
};
export const MY_RATES: IEnginePackage = {
name: "my-rates",
lexerVocabulary: { keywords: { fxrate: "MYRATES_FXRATE" } },
prefixParselets: { MYRATES_FXRATE: fxrateParselet },
pluginFunctions: { fxrate: pluginFunction },
asyncResolvers: [resolver],
tokenCategories: { MYRATES_FXRATE: "function" },
};

packageName and functionName name the plugin function the resolver answers for, the same name the parselet passes to emitPluginCall, and the package registers pluginFunction under it. A line fxrate EUR shows as pending until the fetch lands, then as the rate. The engine refuses to register a package whose resolver names a function its pluginFunctions does not declare, since the call the resolver waits for would never come.

The older form names the function by its plugin index, pluginFunctionIndex, which the built-in weather, stocks, crypto and knowledge packages still use; give one form or the other, and anything else is refused when the resolver is built. A fetchQuery that throws, or resolves to something that is not a Value, is answered as a failed fetch, with an error on the line.

A resolver written by hand implements IAsyncResolver: a namespace, a preflight check, and a destroy for cleanup. Reach for it when a lookup is not one question and one fetch, the way the currency resolver reads a pair of currencies from either side of in.

import type { Token } from "solve-engine/lexer";
import type { BytecodeProgram } from "solve-engine/parser";
import type { AsyncCheckResult, IAsyncResolver } from "solve-engine/resolvers";
import { uomValue, type Value } from "solve-engine/vm";
interface Pair {
from: string;
to: string;
}
// Your syntax, your parse: the currency pair this line asks about, or null.
declare function readPairFromTokens(tokens: Token[]): Pair | null;
class RatesResolver implements IAsyncResolver {
readonly namespace = "myrates";
private readonly cache = new Map<string, Value>();
preflight(tokens: Token[], _bytecode: BytecodeProgram, packageId: string, signal: AbortSignal): AsyncCheckResult | null {
const pair = readPairFromTokens(tokens);
if (!pair) return null; // this line is not for us
const queryKey = `${packageId}:rates:${pair.from}:${pair.to}`;
if (this.cache.has(queryKey)) return null; // already have it, run synchronously
return { queryKey, packageId, signal, resolver: this.fetchRate(queryKey, pair, signal) };
}
private async fetchRate(queryKey: string, pair: Pair, signal: AbortSignal): Promise<Value> {
const response = await fetch(`https://example.com/rate/${pair.from}/${pair.to}`, { signal });
const body = (await response.json()) as { value: number };
const rate = uomValue(body.value, pair.to);
this.cache.set(queryKey, rate);
return rate;
}
destroy(): void {
this.cache.clear();
}
}

Register it on the package alongside the syntax that triggers it:

engine.registerPackage({
name: "my-rates",
asyncResolvers: [new RatesResolver()],
// prefixParselets / infixParselets: the syntax, see writing a package
});

A host can switch live data off (network.enabled: false, see switching live data off). With it off the engine does not call a resolver’s preflight at all, so no request is ever started. A resolver that reads engine state rather than a network, one that waits for a value another line declares, or looks up a table the host loaded up front, declares itself local and keeps running:

class TableResolver implements IAsyncResolver {
readonly namespace = "mytable";
readonly local = true;
// preflight and destroy as above
}

Leave it unset for anything that fetches. The setting is the host’s promise that nothing leaves the process, and a fetching resolver marked local would break that promise on the host’s behalf.

A plugin function that fetches on a cache miss (because its input is known only when the line runs, so preflight had nothing to scan) is the other place a request can start. The engine refuses the promise such a function returns when live data is off, but by then the request has already left. Check the setting the function is handed before starting one:

const pluginFunction = (args: Value[], context?: LineExecutionContext) => {
const queryClient = context?.queryClient;
const cached = queryClient?.getQueryData(keyFor(args));
if (cached !== undefined) return cached as Value;
if (context?.networkEnabled === false) {
return errorValue("NETWORK_DISABLED", "Live data is switched off for this engine (network.enabled is false)");
}
if (queryClient === undefined) return errorValue("MYRATES_NO_CACHE", "No engine cache to fetch into");
return queryClient.fetchQuery({ queryKey: keyFor(args), queryFn: ({ signal }) => fetchIt(args, signal) });
};

The built-in historical exchange rate does exactly this for x in GBP on 2024-01-15, where the source currency is the value of x and preflight cannot see it.

Each engine keeps what its resolvers fetched in a cache of its own, a TanStack Query QueryClient. A plugin function reads that cache from the execution context it is handed, context.queryClient: the engine running the line puts its own cache there, on the first pass, on the re-run when a value lands, and in a what-if pass, which runs on a scratch engine with its own. So two engines in one process each read their own cache, whichever of them ran last.

preflight is handed the same cache as its last argument, so the two halves of a resolver meet in one place. A resolver built with createQueryResolver reads the context for you.

The boundary: getActiveQueryClient(), the one module-level slot a plugin function used to read the cache from, still works and is deprecated. The engine used to publish its cache there before each line and put the previous one back around every nested run, and a re-run that missed one of those steps read another engine’s cache. The engine sets it at every plugin call now, so an old handler keeps reading the right cache, but new code should read the context. The slot is removed in 3.0.

Preflight runs before the VM, and stays synchronous

Section titled “Preflight runs before the VM, and stays synchronous”

preflight is called before an expression executes, for every expression unless you name the opcodes you watch, so it has to be cheap. Its only job is to answer one question: is all the data this line needs already cached?

  • Yes, or the line is not ours: return null. The expression runs normally.
  • No: start the fetch, return an AsyncCheckResult carrying the promise, and return immediately. The engine skips execution and reports Pending.

Do not await inside preflight. It creates the promise and hands it back; the engine waits on it, caches the result, and re-evaluates the line. That re-evaluation finds the data cached, so preflight returns null and the line produces a real value. The pending lifecycle is the consumer’s side of this same loop.

A compiled expression is a list of small numbered instructions, called opcodes: push a number, push a string, add, call a plugin function, read a global. A preflight is a scan of that list for the few instructions it can act on, and it returns null for a line that has none of them. A document has far more plain lines than live ones, so by default most of what a resolver does is say “not mine”.

watchedOpcodes names those instructions up front. For a line that contains none of them the engine gives the null itself, without calling preflight, and a line no registered resolver could intercept skips the preflight altogether, along with the per-evaluation AbortSignal and its link to the keystroke.

import { OpCode } from "solve-engine/parser";
class RatesResolver implements IAsyncResolver {
readonly namespace = "myrates";
// The scan reads the unit names PUSH_STRING carries, so a line with no
// string constant cannot be a conversion.
readonly watchedOpcodes = [OpCode.PUSH_STRING];
// preflight and destroy as above
}

The declaration is a promise about your own preflight: for a line containing none of the listed opcodes, it would have returned null. List every opcode the scan keys on. Where there is a choice, name the one an ordinary line does not carry: the built-in currency resolver watches PUSH_STRING, which every unit name arrives as, rather than the ADD its scan also checks, because ADD is in nearly every line and declaring it would spare nothing. A resolver built with createQueryResolver declares CALL_PLUGIN and CALL_PLUGIN_WIDE for you.

The boundary: leave it unset and nothing changes, preflight runs for every line as it always has, so the cost of not declaring is speed, never a missed lookup. And a line that calls a plugin function is preflighted by every resolver whatever it declared, since that is the one shape whose pending path the virtual machine itself relies on.

Two lines asking 100 USD and 250 USD in the same pair should share one fetch. The queryKey is how the engine knows they are the same request: build it from what the query depends on, not from the whole expression. Same key, one fetch, both lines updated when it lands.

The signal is an AbortSignal that fires when the evaluation it belongs to is superseded, because the user kept typing, or the engine was cleared. Pass it to fetch so a stale request is cancelled rather than resolving into a document that has moved on. This is what stops a slow response from overwriting a newer answer.

A document can ask for hundreds of values at once: a pasted list of places, each on its own line, is one weather lookup per line. Started together, those are hundreds of connections to one service from the reader’s own address, which a service may throttle or block, and which a hostile document could use on purpose. A resolver built with createQueryResolver runs at most six fetches at once and queues the rest, in the order they were asked for. maxConcurrent sets the number:

import { createQueryResolver } from "solve-engine/resolvers";
const { resolver, pluginFunction } = createQueryResolver({
namespace: "tides",
packageName: "my-tides",
functionName: "tide",
fetchQuery: (port, signal) => fetchTide(port, signal),
maxConcurrent: 2, // this service allows two connections per client
timeoutMs: 8_000,
});

The contract for a queued fetch:

  • The timeout starts when the fetch does. timeoutMs counts the request, not its wait for a slot, so a long queue does not time out requests that never ran.
  • A fetch that ignores its signal still gives its slot back at the deadline. The resolver stops waiting for it then, and the line gets the timeout error; a request already sent cannot be recalled.
  • A query asked for again while it waits shares the one fetch, as it does once running, because the query key is the deduplication.
  • A query cancelled while it waits leaves the queue and never fetches: the signal fetchQuery would have been given aborts first, as it does when the query client cancels the query.

The limit is one per resolver, shared by every engine in the process that registers the package. It is a positive whole number, or Infinity to switch it off, and any other value is refused when the package is built.

The boundary: this bounds how many requests run together, not how many run. A document of 500 places still makes 500 lookups, six at a time; a rate per minute is not part of it. A resolver you write by hand, without createQueryResolver, has no limit unless it keeps one of its own.

By default a resolved value refreshes only when its line is re-evaluated and has gone stale. To keep an on-screen value fresh while a note sits open, declare a cadence on the AsyncCheckResult your preflight returns: refetchIntervalMs is how often to refetch, and refetch forces a fresh fetch, past your cache, and returns the new value.

return {
queryKey,
packageId,
signal,
resolver: this.fetchRate(queryKey, pair, signal),
refetchIntervalMs: 60_000, // refresh an on-screen rate once a minute
refetch: () => this.fetchRate(queryKey, pair, this.refreshSignal),
};

The engine drives the refetch for the values currently on screen and pushes each fresh result to the host on the event stream, the same one the pull path uses. It does nothing unless the host enabled background refresh (backgroundRefresh.enabled), and a value with no cadence stays pull-only. A value no line references any more stops at once, and a refetch still running when the next is due is skipped rather than stacked, so give refetch an AbortSignal you own and abort in destroy(). The pending lifecycle is the consumer’s side of this.

A figure a resolver fetches is true at one moment according to one provider, and a host can only say so if the value does. Every Value has a sources field for that: a list of records naming the provider, how the figure was obtained, and when (see where a live value came from for what a host does with it). Set it on the value your fetch returns, and the engine carries it through every line computed from that value; you do nothing further.

private async fetchRate(queryKey: string, pair: Pair, signal: AbortSignal): Promise<Value> {
const res = await fetch(`https://example.com/rate/${pair.from}/${pair.to}`, { signal });
const rate = await res.json();
const value = uomValue(rate.value, pair.to);
value.sources = [{
provider: "Example Rates", // who supplied it, as a reader should see it
kind: "live", // or "historical" for a figure for a past day
fetchedAt: Date.now(), // when it arrived, in epoch milliseconds
subject: `${pair.from}/${pair.to}`,
}];
this.cache.set(queryKey, value);
return value;
}

A resolver built with createQueryResolver does this for you: it stamps each value its fetchQuery returns with a live record whose provider is the option of that name (the namespace if you leave it out), whose subject is the query, and whose fetchedAt is the moment the fetch returned. A value that already carries sources is left as it is, which is how a package records what only it knows. The stocks package’s historical close does this:

const value = uomValue(quote.close, quote.currency ?? "USD");
value.sources = [{ provider, kind: "historical", fetchedAt: Date.now(), subject: ticker, asOf: isoDate }];
return value;

The built-in packages name their providers: Frankfurter and CoinGecko for the rates the engine fetches itself (live rates, and historical rates for a past day), Open-Meteo for weather, and for the packages that take a host’s fetch (stocks, crypto, knowledge) a provider option, host by default. A host that replaces the historical exchange rate with its own names it with historicalProviderName.

Three boundaries. A failed fetch returns an Error value, which is not a figure and carries no record, so do not stamp one. The record is set once, as the value arrives; do not change it on a value you have already returned, because the engine shares that value between every line that reads it. And a line the reader froze (... frozen) never reaches your resolver once its answer is kept: the engine answers it from its own store, so a resolver needs no special case for frozen answers.

A package can ship a working source and still let a host bring its own. The currency package’s historical rate is the worked example. createCurrencyPackage takes a historicalRateProvider option:

ValueWhat answers 100 USD in GBP on 2024-01-15
left outthe built-in Frankfurter provider (the European Central Bank’s reference rates)
a functionthe host’s function, which takes precedence
nullnothing: the line reports HISTORICAL_RATES_NOT_CONFIGURED

The provider’s contract is small: (from, to, isoDate, signal) => Promise<number | { rate, asOf }>. It receives upper-case currency codes, the day as YYYY-MM-DD and the signal to pass to fetch; it returns the rate, with asOf when the rate it found was published for an earlier day (a weekend asked for, Friday answered). Three things are the package’s job, not the provider’s, and hold whichever provider answers: the query key and its never-stale cache (a past day’s rate does not change), the timeout, and refusing an answer that is not a finite, positive number rather than converting through it.

The built-in provider decides its own boundary before any request and says why in the error: a day before 4 January 1999 (HISTORICAL_RATE_DATE_OUT_OF_RANGE, since earlier figures are not ECB reference rates), a day after today (the same code), and a currency the ECB does not quote (HISTORICAL_RATE_UNSUPPORTED_CURRENCY). A host provider that throws an EngineError with one of those two codes has its refusal shown as it is; anything else it throws becomes HISTORICAL_RATE_QUERY_FAILED, named with the pair and the day.

The same provider can be built with a stub fetch, which is how its tests run without the network:

import { createFrankfurterHistoricalRateProvider } from "solve-engine/packages";
const provider = createFrankfurterHistoricalRateProvider({
fetch: async () => new Response(JSON.stringify([{ date: "2024-01-15", base: "USD", quote: "GBP", rate: 0.78441 }])),
});

Tests that call a real service belong outside the gates: the engine’s own run only with SOLVE_LIVE_NETWORK=1 (npm run test:live), where a timeout, a 5xx and a 429 count as outages rather than failures.

A data source is tested by what a line resolves to, with a stub in place of the service, so the test never reaches the network. The first evaluation is Pending by contract; toResolveTo from solve-engine/testing waits for the fetch (through engine.settle(), see waiting for every value to settle), evaluates the line again and compares the answer. A failed fetch is a settled result too, read with settled() and toFailWith.

Build the package from a factory that takes its fetch, the way the built-in stocks package takes fetchQuote, and the test hands it a stub. Testing a package walks a complete example (its code is compiled and run with the engine’s own tests), and expectDocument tests a data source read across lines. The package starter does the same for a live lookup, with the hostile inputs a data source should refuse.

The currency package is the smallest built-in that does all of this: a symbol and an in parselet for the syntax, CurrencyAsyncResolver for the live fetch, and uom/HistoricalCurrency.ts with uom/FrankfurterHistoricalRates.ts for the dated one. Read them alongside this page for the parts the skeleton above leaves to you.