Live Queries
Live queries reuse the same browser query object for initial fetch, updates, invalidation, and authorization.
Kizaki live queries are query-centered. You build a query object in the browser, fetch it once or subscribe to it live, and the platform applies the same read policy to both. There is no separate subscription schema, no parallel authorization model, no second cache layer.
How Live Queries Work
The browser constructs a SelectBuilder query object describing what the UI wants to read. When this object is passed to useQuery(), two things happen in sequence: the platform fetches the current result set, then subscribes over WebSocket for changes.
import { select } from "@kizaki/sdk";
import { Message } from "@kizaki/schema";
const messagesQuery = select(Message)
.fields(Message.id, Message.text, Message.createdAt)
.orderBy(Message.createdAt, "desc")
.limit(50);
const { data, status } = useQuery(messagesQuery);When the underlying data changes (another user inserts a message, an existing message is edited, a row is deleted) the database engine re-evaluates the query with the same policy enforcement that applied to the initial fetch. The updated result set is pushed to the browser. The component re-renders with the new data. Application code does not manage diffs or reconciliation.
Authorization Stays Consistent
The same @grant and @deny rules that govern one-shot queries govern every live query refresh. If a user loses read access to a row (removed from a team, role revoked, policy condition no longer met) the live query result set automatically shrinks. The browser does not see stale rows it should no longer access.
The reverse is also true. If a user gains access to rows they could not previously read, those rows appear in the next refresh without the UI needing to know anything changed.
There is no window where the browser holds data the principal is no longer authorized to see. The live query is not just a data subscription. It is a continuously enforced read policy.
The Query Object As Anchor
The same query object serves four roles:
- Initial fetch. The first result set the UI renders.
- Live updates. The subscription the server maintains for ongoing changes.
- Cache key. React's rendering layer uses the query object to deduplicate and cache results.
- Authorization scope. The platform evaluates grants and denies against the query on every refresh.
There is no separate subscription definition, no channel name, no topic string. The query is the subscription.
const messagesQuery = select(Message)
.fields(Message.id, Message.text, Message.createdAt)
.orderBy(Message.createdAt, "desc")
.limit(50);
// This one object drives: initial load, live updates, cache invalidation, and auth
const { data, status } = useQuery(messagesQuery);You can compose queries with the full SelectBuilder API (filters, joins, ordering, limits) and every feature carries through to the live subscription. A filtered live query only receives updates for rows that match the filter.
Invalidation vs. Live Refresh
Two mechanisms keep the UI current, and they work together.
Explicit invalidation fires after a local write completes. When you wrap a mutation with useMutation, you can declare which queries to refetch:
const sendMessage = useMutation(rpc.sendMessage, {
invalidate: [messagesQuery],
});After sendMessage succeeds, the platform refetches messagesQuery so the UI reflects the local write immediately. Local writes feel instant.
Server-driven refresh fires when any change (from any user, any device, any function) affects the query's result set. This is the live channel. It covers everything invalidation does not: writes from other users, background jobs, triggered side effects.
Both mechanisms coexist. Local writes feel responsive through invalidation. Remote changes arrive through the live channel. The UI does not need to know which mechanism delivered a given update.
Performance And Scope
Each live query is a subscription the server maintains. A few guidelines keep this efficient:
Keep reads narrow. Use .fields() to return only the columns the component needs. A chat message list does not need Message.editHistory or Message.attachmentMetadata if it only renders text.
// Narrow: only the fields the component renders
const feed = select(Activity)
.fields(Activity.id, Activity.summary, Activity.createdAt)
.orderBy(Activity.createdAt, "desc")
.limit(25);Use .limit() for feeds and streams. An activity feed showing the latest 25 items is a lightweight subscription. An unbounded query over a table with 100,000 rows is not.
Choose live queries for screens that need to stay current. Chat interfaces, collaborative editors, dashboards with live metrics, notification feeds. These benefit from continuous updates.
Use one-shot fetch() for pages that load once. Settings pages, user profiles, onboarding flows. These rarely change while the user is looking at them. A single fetch is simpler and cheaper than maintaining a subscription.
// One-shot fetch for a settings page
const { data: settings } = useQuery(
select(TeamSettings).where(eq(TeamSettings.teamId, teamId)),
{ live: false }
);One Read Model, One Authorization Model
When you add live queries to a screen, you are not adopting a new paradigm. You keep the same query you already wrote and ask the platform to keep it current. One read model, one authorization model, one mental model in the browser. That coherence is what lets realtime feel like a normal part of application development instead of a special subsystem.