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.
The contract
Section titled “The contract”A resolver implements
IAsyncResolver: a namespace, a
preflight check, and a destroy for cleanup.
import type { IAsyncResolver, AsyncCheckResult } from "solve-engine/resolvers";import { uomValue, type Value } from "solve-engine/vm";
class RatesResolver implements IAsyncResolver { readonly namespace = "myrates";
preflight(tokens, bytecode, packageId, signal): AsyncCheckResult | null { const pair = readPairFromTokens(tokens); // your syntax, your parse 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(pair, signal), }; }
private async fetchRate(pair, signal): Promise<Value> { const res = await fetch(`https://example.com/rate/${pair.from}/${pair.to}`, { signal }); const rate = await res.json(); this.cache.set(/* queryKey */, rate); return uomValue(rate.value, pair.to); }
destroy() { 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});Preflight runs before the VM, and stays synchronous
Section titled “Preflight runs before the VM, and stays synchronous”preflight is called for every expression, before it executes, 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
AsyncCheckResultcarrying the promise, and return immediately. The engine skips execution and reportsPending.
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.
The query key is the deduplication
Section titled “The query key is the deduplication”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.
Honour the signal
Section titled “Honour the signal”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 complete reference
Section titled “A complete reference”The currency package is the smallest built-in that does all of this: a symbol
and an in parselet for the syntax, and CurrencyAsyncResolver for the fetch.
Read it alongside this page for the parts the skeleton above leaves to you.