Skip to content
Runtime
Framework

Writing an adapter

If your environment can store and change a query, it can back a QueryWeave runtime. The contract is four methods, and the constraints matter more than the code.

interface QuerySource {
read(): QueryInput;
}
interface QueryAdapter extends QuerySource {
push(next: QueryOutput): QueryNavigationResult | void | Promise<QueryNavigationResult | void>;
replace(next: QueryOutput): QueryNavigationResult | void | Promise<QueryNavigationResult | void>;
subscribe(listener: QueryChangeListener): () => void;
}

If the environment hands you a query once and then ends — a request, a message, a job payload — implement QuerySource. Do not implement QueryAdapter with methods that throw; the absence of a capability belongs in the type.

Mutable environment — reads, navigates, and notifies

Mutable environment — reads, navigates, and notifies

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

QueryModel → QueryRuntime. QueryRuntime → QueryAdapter. QueryAdapter → Your environment.

An adapter must not:

  • decode, validate, or apply defaults,
  • reorder, deduplicate, or drop entries,
  • re-implement default omission,
  • hold decoded state,
  • throw when the environment merely refuses a navigation.

An adapter must:

  • preserve repeated keys and their order,
  • resolve its target when it is created, not at module evaluation,
  • notify subscribers once per external change,
  • return a working unsubscribe function,
  • provide deterministic cleanup.
  1. Read. Return whatever raw shape you have. Anything QueryInput accepts is fine — a string, entries, or an object — so you rarely need to convert.

    const read = (): QueryOutput => parseQueryString(store.query);
  2. Write. Format the canonical output and hand it to the environment.

    const write = (next: QueryOutput, mode: "push" | "replace"): void => {
    const search = formatQueryString(next);
    store.navigate(search === "" ? path : `${path}?${search}`, mode);
    };
  3. Notify. Tell subscribers once, with the new raw input.

    const notify = (): void => {
    const input = read();
    for (const listener of [...listeners]) {
    listener(input);
    }
    };

    Copy the listener set before iterating, so a listener that unsubscribes during notification does not corrupt the loop.

  4. Subscribe lazily. Attach the environment listener on the first subscription, not on creation.

    const subscribe = (listener: QueryChangeListener): (() => void) => {
    attach();
    listeners.add(listener);
    return () => {
    listeners.delete(listener);
    };
    };
  5. Dispose. Detach, clear, and make later writes fail loudly.

Does your environment fire a change event for programmatic writes?

Section titled “Does your environment fire a change event for programmatic writes?”

This is the question that decides whether you notify after writing.

  • The History API does not fire popstate for pushState, so @queryweave/browser notifies directly after writing.
  • Vue Router does, through its reactive current route, so @queryweave/vue-router watches fullPath and does not notify from push itself.

Getting this wrong produces either no notification or two. The rule to hold onto is that exactly one notification must reach the runtime per change, whatever its source.

If the environment can refuse — a navigation guard, a rejected route — return the outcome from push or replace rather than throwing into the caller’s transition:

const push = async (next: QueryOutput): Promise<QueryNavigationResult | void> => {
const failure = await store.navigate(formatQueryString(next));
if (failure !== undefined) {
return { outcome: "refused", reason: failure };
}
// Returning nothing means committed.
};

outcome is "committed", "refused" (the environment declined and is unchanged), or "redirected" (it accepted the write but ended somewhere else); reason carries the environment’s own account. The runtime puts both on the transition result. A transition that was refused is an environment condition, not a programming error; throw only for real errors, such as a disposed adapter or an environment that rejects the write itself. @queryweave/vue-router does exactly this.

The runtime skips a write whose output already equals what read() returns, so an adapter never has to detect a no-op itself.

Test it against the engine, not on its own. The behaviors worth asserting are:

// It reports what it stores.
expect(adapter.read()).toStrictEqual([["page", "2"]]);
// One change, one notification.
expect(notifications).toHaveLength(1);
// Repeated keys survive a round trip.
adapter.push([
["tag", "a"],
["tag", "b"],
]);
expect(runtime.read().values.tags).toStrictEqual(["a", "b"]);
// Unsubscribing works.
unsubscribe();
adapter.push([]);
expect(notifications).toHaveLength(1);

Compare behavior with createMemoryQueryAdapter from @queryweave/testing: if the two disagree for the same operations, the new adapter is doing something the contract does not allow. Its guard option shows what a refusing environment should look like from the runtime’s side.

If you publish an adapter, keep the dependency direction QueryWeave uses: depend on @queryweave/core alone, declare the environment’s framework as a peer dependency rather than a dependency, and do not pull in a validation library. The architecture page explains why those boundaries exist.