Query Advanced
Realtime
Server events change the cache through declared rules; a browser with several tabs open polls and listens once.
Markdown version of this page, for assistants (Markdown for AI)
Note ./live and ./tabs are experimental: the API can still change in a minor release.
Stability per entry point: Entry points.
On this page
Setup
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { createClient, setClient } from '@svelte-lean/query';
import { http } from '@svelte-lean/query/http';
import { live } from '@svelte-lean/query/live';
import { tabs } from '@svelte-lean/query/tabs';
import { can, session } from '$api/session.svelte';
import { memberRules } from './rules';
import { eventSource } from './source';
let { children } = $props();
setClient(
createClient({
transport: http({ base: '/' }),
scope: () => [session.workspace],
can,
modules: [
live(eventSource('/events'), memberRules),
// One poller and one socket per browser; tabs of another user never share.
tabs({ session: () => (session.userId === null ? null : String(session.userId)) })
]
})
);
</script>
{@render children()}Both are modules of the client. Call sites do not change: a list that is open polls and receives
events with the same createQuery.
Live source
// src/lib/live-source.ts — a LiveSource over server-sent events: one EventSource per channel.
// A Pusher, Reverb or WebSocket client is wrapped the same way.
import type { LiveSource } from '@svelte-lean/query/live';
export function eventSource(base: string): LiveSource {
return {
subscribe({ channel, event }, deliver, ready) {
const events = new EventSource(`${base}/${encodeURIComponent(channel)}`);
const listener = (message: MessageEvent<string>) => deliver(JSON.parse(message.data));
events.addEventListener(event, listener);
events.addEventListener('open', () => ready(), { once: true });
return () => {
events.removeEventListener(event, listener);
events.close();
};
}
};
}A LiveSource subscribes to one channel and event, delivers payloads and calls ready() once the server confirmed. A Pusher, Reverb or WebSocket client is wrapped
the same way; an optional watch() reports the connection, and a reconnect reseeds.
Rules
| Kind | Shape | Effect |
|---|---|---|
| invalidate | rule(tag, …) | The event says something changed; observed entries with the tag refetch, coalesced per burst; unobserved ones are marked stale. |
| patch an entity | patch: (m) => patch(tag(…), …) | The event carries the row; every cached page, list and detail holding it is updated without a request. |
| patch an entry | patch: (m) => patch([endpoint, params], …) | The event carries a field of one entry; that entry is changed without a request. |
Invalidate
// Poke, then pull: the observed lists tagged Members refetch, coalesced per burst.
rule('Members', { channel: 'members', event: 'member.created' }),Patch
// The event carries the row: every cached page, list and detail holding it is patched.
rule('Members', {
channel: 'members',
event: 'member.updated',
message: type<{ member: Member }>(),
patch: (m) => patch(tag('Member', m.member.id), () => m.member)
}),Patch one entry
// Change one field of the one cached detail the message names.
rule(getMember, {
channel: (params) => `members.${params.id}`,
event: 'presence.changed',
message: type<{ id: number; status: Member['status'] }>(),
patch: (m) => patch([getMember, { id: m.id }], (member) => ({ ...member, status: m.status }))
})The rule's patch calls the same patch() as the rest of the
application. Write the absolute new value, never a relative change, so an event applied twice
gives the same result. message validates the payload; an event that fails it is
dropped. A rule with patch invalidates nothing unless it says invalidate.
Behavior
- Nothing subscribes until an entry a rule targets is observed.
- Channels are reference-counted and kept for
lingerafter the last observer left. - A confirmed subscription and a reconnect invalidate the rule's targets (
reseed), so events missed while away are not lost. - A burst of events is one refetch per observed entry, with at most one request in flight per entry.
| Option of live() | Default | Effect |
|---|---|---|
window | '100ms' | Coalescing window per burst of events. |
maxWindow | '2s' | Ceiling of the window under sustained bursts. |
linger | '1.5s' | How long a channel stays after its last observer left. |
Tabs
With tabs(), one tab polls each polled entry and holds the live connection (Web
Locks); the others receive what it learns over a BroadcastChannel: invalidations (a
save in one tab refreshes the lists of the others), changed values and forwarded events.
| Option | Type | Default | Effect |
|---|---|---|---|
session | () => string | null | none | Partition beside the client scope; tabs of another session never share. null: this tab works alone. |
share | number | false | 16384 | Largest value (JSON text length) sent with a changed entry; above it the other tabs refetch. |
name | string | 'slq' | Namespace of the channel and lock names. |
- A visible tab takes a poll over from a hidden one; a closed, crashed or frozen leader is replaced.
- Tabs of another session (or another scope) never hear each other.
- Without
BroadcastChannelor Web Locks every tab works alone, as without the module.
Measured
A list polled every two seconds, open in several pages of one browser context (benchmark P6, production build, median of five runs):
| Pages | List requests per minute | Live connections |
|---|---|---|
| 1 tab, with ./tabs | 30 | 1 |
| 2 tabs, with ./tabs | 30 | 1 |
| 5 tabs, with ./tabs | 30 | 1 |
| 5 tabs, without the module | 150 | 5 |
Method and environment: Performance.
Reference
- @svelte-lean/query/live
- @svelte-lean/query/tabs
- Testing:
fakeSource()emits events in tests