Skip to content
QueryWeave

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

  1. QueryModel (meaning)
  2. QueryRuntime (transitions)
  3. QueryAdapter (synchronization)
  4. Environment (environment)

QueryModel → QueryRuntime. QueryRuntime → QueryAdapter. QueryAdapter → Environment.

The runtime is the only layer that holds both a model and an environment. Neither of the layers it joins knows about the other.
const snapshot = runtime.read();
snapshot.status; // "valid" | "invalid" | "pending"
snapshot.values; // complete typed state, always present, frozen
snapshot.issues; // everything decoding reported
snapshot.result; // the underlying DecodeResult

read 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"

Five named operations, all asynchronous, all returning what they wrote:

await runtime.update({ page: 2 }); // merge a patch
await runtime.replace(nextValues); // swap the whole state
await runtime.remove(["search"]); // omit keys from the output
await runtime.reset(); // back to declared defaults
await 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 used
outcome; // "committed" | "redirected" | "refused" | "unchanged" | "cancelled"
reason; // the adapter's account of a refusal or redirect, or the signal's reason for a cancellation
output; // the canonical entries the adapter was asked to write, including unmanaged keys
snapshot; // the settled state after the transition, whatever its outcome

Transitions covers each operation and when to reach for it.

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 notification

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

model.encode emits only the keys the model declares. The runtime is what preserves the rest:

// current URL: ?page=2&utm_source=newsletter
await runtime.update({ page: 3 });
// written: ?page=3&utm_source=newsletter

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

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.

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:

  • throttle holds a burst and writes it together when the window closes, so a search field needs no debounce of its own.
  • signal abandons a transition that has not applied its change yet, with a cancelled outcome.

There is no debounce and no automatic merging without a window. The reasoning is in ADR 0010.