Reactive queries
Declare a query once. It stays correct forever, across components, tabs, devices and backend jobs.
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.
| Method | Purpose |
|---|---|
.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.
Pulling in related records
.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.
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.
Subscribing with useQuery
useQuery(db, factory) registers the plan and keeps the result current for the lifetime of the
component.
| Accessor | Returns |
|---|---|
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”. |
The Solid 2 binding (@spooky-sync/client-solid2) sharpens these: isLoading() is true only on a
cold first load (nothing cached, server not heard yet), isAuthoritative() says the server’s
membership is known, and isEmpty() is the server saying “no rows”. See the
Solid 2 guide.
Two options are worth knowing:
| Option | Effect |
|---|---|
enabled: () => boolean | Skip registration until the accessor returns true. |
deregisterOnCleanup: true | Tear 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.
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.
For SurrealQL the builder can’t express, db.queryRaw(sql, params, ttl) is the escape hatch. Raw
queries are not reactive.
For a one-shot read straight from the server, a count() for a virtualized list say, use
db.remoteQuery(sql, params). It runs the statement through the client’s own remote path (the
connect gate, the per-statement timeout and the concurrency limit) and resolves with the SurrealDB
result array. Prefer it over db.useRemote(s => s.query(...)), which hands out the bare SDK
client and can race a reconnect.
All three bypass the query builder, so with the query allowlist
switched on (sync.queryAllowlist: warn or enforce) they throw unless the client config sets
allowRawRemote: true.