sveltelean Query
Version v0.2.0

Setup

src/routes/+layout.svelte
<!-- 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
// 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

KindShapeEffect
invalidaterule(tag, …)The event says something changed; observed entries with the tag refetch, coalesced per burst; unobserved ones are marked stale.
patch an entitypatch: (m) => patch(tag(…), …)The event carries the row; every cached page, list and detail holding it is updated without a request.
patch an entrypatch: (m) => patch([endpoint, params], …)The event carries a field of one entry; that entry is changed without a request.

Invalidate

poke, then pull
// 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
// 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

one field of 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 linger after 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()DefaultEffect
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.

OptionTypeDefaultEffect
session() => string | nullnonePartition beside the client scope; tabs of another session never share. null: this tab works alone.
sharenumber | false16384Largest value (JSON text length) sent with a changed entry; above it the other tabs refetch.
namestring'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 BroadcastChannel or 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):

PagesList requests per minuteLive connections
1 tab, with ./tabs301
2 tabs, with ./tabs301
5 tabs, with ./tabs301
5 tabs, without the module1505

Method and environment: Performance.

Reference