Github|...

Reactive queries

Declare a query once. It stays correct forever, across components, tabs, devices and backend jobs.

UserList.tsx
import { useQuery } from '@spooky-sync/client-solid';
import { For } from 'solid-js';
import { db } from './db';

function UserList() {
  const users = useQuery(db, () =>
    db.query('user')
      .orderBy('created_at', 'desc')
      .limit(20)
      .build()
  );

  return (
    <ul>
      <For each={users.data() || []}>
        {(user) => <li>{user.username}</li>}
      </For>
    </ul>
  );
}

That list is live. When anyone creates, edits, or deletes a user, in this component, in another tab, on another device, or from a backend job, the component re-renders with the new data. You never write a refetch, an invalidation rule, or a subscription teardown.

Building a query

db.query(table) returns a fluent builder. Nothing runs until you .build(), and .build() returns a plan, not results.

const query = db.query('user')
  .where({ age: { _op: '>', _val: 18 } })
  .orderBy('created_at', 'desc')
  .limit(10)
  .build();          // a plan, not a result
MethodPurpose
.where(conditions)Filter rows. See below.
.select(...fields)Restrict the projection. Defaults to every column.
.related(field)Replace a record id with the full related record.
.orderBy(field, dir)Sort. Chain calls for multi-key ordering.
.limit(n) / .offset(n)Window the result. See Pagination.
.one()Return a single record instead of an array.
.build()Freeze the plan so useQuery can register it.

Filtering

A plain value means equality. For anything else, pass a comparison descriptor: _op is the operator, _val the value. A top-level _or compiles to a parenthesised OR group.

// equality
.where({ status: 'open' })

// comparison: _op is the operator, _val the value
.where({ age: { _op: '>=', _val: 18 } })

// several fields are ANDed together
.where({ status: 'open', age: { _op: '<', _val: 65 } })

// _or produces (white = $or0 OR black = $or1)
.where({ _or: [{ white: userId }, { black: userId }] })

.related() follows a record<...> field and returns the whole record instead of an id. One call covers 1:1, 1:N and graph edges. Sp00ky reads your schema.surql to work out the traversal.

const threads = useQuery(db, () =>
  db.query('thread')
    .related('author')
    .orderBy('created_at', 'desc')
    .limit(10)
    .build()
);

threads.data()?.forEach((thread) => {
  console.log(thread.title);
  console.log(thread.author?.username); // typed
});
Note

You never write graph paths like ->liked->post or join conditions. The relation table you declared in the schema is enough for Sp00ky to generate the right query.

The return type changes with the call: without .related() the field is a RecordId, with it the field is the related object. Accessing a relation you didn’t fetch is a compile error.

const post = posts[0];

// ✅ fetched via .related('author')
console.log(post.author.username);

// ❌ Property 'email' does not exist on type 'User'
console.log(post.author.email);

Subscribing with useQuery

useQuery(db, factory) registers the plan and keeps the result current for the lifetime of the component.

const threads = useQuery(db, () => db.query('thread').build());

threads.data();       // Thread[] | undefined
threads.isSettled();  // false until the first server-confirmed result
AccessorReturns
data()Current rows, or undefined before the first result.
error()The last error, if the query failed.
isLoading()true until there is anything to show.
isFetching()true while a fetch is in flight, including refreshes.
isSettled()true once the query has a server-confirmed result. Use this, not isLoading, to decide “the list really is empty”.

Two options are worth knowing:

OptionEffect
enabled: () => booleanSkip registration until the accessor returns true.
deregisterOnCleanup: trueTear the synced view down on unmount instead of keeping it warm. See Pagination.

If your app is wrapped in <Sp00kyProvider>, you can drop the first argument and call useQuery(() => …). The client comes from context.

Because the factory is a function, anything reactive you read inside it re-registers the query when it changes. That’s how filters, sort order and pagination all work. You change a signal, and the live window follows.

const [status, setStatus] = createSignal('open');

// re-registers whenever status() changes
const results = useQuery(db, () =>
  db.query('thread')
    .where({ status: status() })
    .build()
);

Under the hood useQuery calls subscribe(queryHash, callback, { immediate: true }). The immediate flag fires the callback synchronously with whatever is already in the local store, so the first paint has data even offline. UPDATE events are debounced by streamDebounceTime (default 50 ms, see Client config); CREATE and DELETE fire immediately. Subscriptions tear down on unmount.

One-off reads

Sometimes you want a value rather than a subscription, inside an event handler say. Call .run() on the built query and await it.

const user = await db.query('user')
  .where({ id: userId })
  .one()
  .build()
  .run();

// arbitrary SurrealQL, not reactive
const rows = await db.queryRaw('SELECT count() FROM thread GROUP ALL', {}, 60);

For SurrealQL the builder can’t express, db.queryRaw(sql, params, ttl) is the escape hatch. Raw queries are not reactive.

Next