The query runtime
A model says what a query means. A runtime connects that meaning to a place that stores it.
import { createQueryRuntime } from "@queryweave/core";
const runtime = createQueryRuntime({ model: products, adapter, navigation: "push", // the default for every transition on this runtime throttle: 0, // milliseconds between writes; 0 writes every transition on its own});Mutable environment — reads, navigates, and notifies
Mutable environment — reads, navigates, and notifies
- QueryModel (meaning)
- QueryRuntime (transitions)
- QueryAdapter (synchronization)
- Environment (environment)
QueryModel → QueryRuntime. QueryRuntime → QueryAdapter. QueryAdapter → Environment.
Reading
Section titled “Reading”const snapshot = runtime.read();
snapshot.status; // "valid" | "invalid" | "pending"snapshot.values; // complete typed state, always present, frozensnapshot.issues; // everything decoding reportedsnapshot.result; // the underlying DecodeResultread compares the adapter’s current entries with the last ones it decoded and decodes again only
when they changed, so a snapshot is never stale relative to the environment and the same snapshot
object comes back until the query moves.
When decoding fails, values is the model’s defaults merged with whatever did decode. That is why
values is safe to render unconditionally, and why status — not a boolean on the snapshot —
carries the distinction. A required parameter that failed is absent from values at runtime even
though its type says otherwise; give runtime-bound parameters a default, or check status before
trusting a required key.
"pending" appears only for a model with an asynchronous refinement: the runtime has decoded
synchronously, recovered the asynchronous keys, and started decodeAsync. Subscribers are notified
once it settles, and runtime.settled() resolves with the settled snapshot:
const settled = await runtime.settled(); // status is "valid" or "invalid"Changing state
Section titled “Changing state”Five named operations, all asynchronous, all returning what they wrote:
await runtime.update({ page: 2 }); // merge a patchawait runtime.replace(nextValues); // swap the whole stateawait runtime.remove(["search"]); // omit keys from the outputawait runtime.reset(); // back to declared defaultsawait runtime.transaction((draft) => { // several changes, one write draft.search = "vue"; draft.page = 1;});Each returns a QueryTransitionResult:
const { navigation, outcome, reason, output, snapshot } = await runtime.update({ page: 2 });
navigation; // "push" | "replace" — what was actually usedoutcome; // "committed" | "redirected" | "refused" | "unchanged" | "cancelled"reason; // the adapter's account of a refusal or redirect, or the signal's reason for a cancellationoutput; // the canonical entries the adapter was asked to write, including unmanaged keyssnapshot; // the settled state after the transition, whatever its outcomeTransitions covers each operation and when to reach for it.
One transition, one notification
Section titled “One transition, one notification”Every transition follows the same path: start from the current typed state, apply the change once, encode once, navigate once, notify once.
The notification does not come from the transition. It arrives through the adapter’s subscription,
because the environment is the source of truth — a browser back button and a call to update must
produce the same kind of event, or subscribers would have two code paths to reconcile.
const seen: number[] = [];const unsubscribe = runtime.subscribe((snapshot) => { seen.push(snapshot.values.page); render(snapshot.values);});
await runtime.update({ page: 2 });seen; // [2] — one transition, one notificationThe runtime subscribes to the adapter lazily, on the first subscribe call, and releases that
subscription when the last listener leaves, so a runtime that is only ever read never attaches a
listener. A listener must not throw: every listener still runs, and the error is rethrown
afterwards to whoever triggered the notification.
Unmanaged keys survive
Section titled “Unmanaged keys survive”model.encode emits only the keys the model declares. The runtime is what preserves the rest:
// current URL: ?page=2&utm_source=newsletterawait runtime.update({ page: 3 });// written: ?page=3&utm_source=newsletterManaged keys are written first, in the model’s definition order, and unmanaged entries follow in their original order. This is what makes QueryWeave adoptable for part of a URL rather than all of it.
Disposal
Section titled “Disposal”runtime.dispose();Disposal clears the subscribers, detaches the adapter subscription, and makes every later operation throw. It is idempotent. In a framework binding this is wired to the component scope for you; in plain TypeScript, call it when the runtime’s owner goes away.
Scheduling
Section titled “Scheduling”Transitions run one at a time, in call order, and each starts from the state the previous one
produced. By default each is its own write: ten rapid updates are ten writes and, in push mode,
ten history entries. Two things change that, both covered in Transitions:
throttleholds a burst and writes it together when the window closes, so a search field needs no debounce of its own.signalabandons a transition that has not applied its change yet, with acancelledoutcome.
There is no debounce and no automatic merging without a window. The reasoning is in ADR 0010.